{
    "version": "https://jsonfeed.org/version/1",
    "title": "Luke Manning - Blog",
    "home_page_url": "https://lukemanning.ie",
    "feed_url": "https://lukemanning.ie/feed.json",
    "description": "Breaking things. Building things. Writing about it.",
    "items": [
        {
            "id": "https://lukemanning.ie/blog/repo-audit-gitignore-lockfile",
            "content_html": "<p><strong>TL;DR:</strong> I asked opencode to audit this repo's config against best practices for a private solo repo. Secrets hygiene came back clean. The genuine surprise was line 7 of my own <code>.gitignore</code>, which said <code>/package-lock.json</code>, so my lockfile was never tracked, and Dependabot vulnerability alerts were switched off. Also ~40 stale branches, a loud <code>git branch -d</code> refusal, and the last command in the <code>&#x26;&#x26;</code> chain silently never running because of it. Fixed in commit <code>42cf1f0</code>, plus two git aliases that encode the right cleanup order, <code>git pull &#x26;&#x26; git sweep</code>.</p>\n<hr>\n<p>I asked my agent (<a href=\"/blog/anthropic-open-source-walled-garden-clawdbot-opencode\">opencode</a>) to review this repo's config. The whole ask was to check ManningWorks/lukemanning-site against best practices for a private solo-dev repo and tell me if anything needs changing.</p>\n<p>My honest expectation was a list of green ticks and maybe a nitpick about something I'd decided on purpose. The site builds, Vercel deploys it, nothing's on fire. What could be wrong?</p>\n<p>Most of it was fine. Some of it really wasn't.</p>\n<h2>The parts that were fine</h2>\n<p>Secrets hygiene, the part I actually cared about, came back clean. <code>.env*</code> is gitignored and <code>.env.local</code> holds a real GITHUB_TOKEN and a DEV_TO_API_KEY, untracked. Better still, the audit ran this check</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> log</span><span style=\"color:#79B8FF\"> --all</span><span style=\"color:#79B8FF\"> --oneline</span><span style=\"color:#79B8FF\"> --</span><span style=\"color:#9ECBFF\"> '.env*'</span></span></code></pre>\n<p>Empty output. No env file was ever committed, on any branch, ever. That's the check I'd never have thought to run.</p>\n<p>Ignores for <code>node_modules</code>, <code>.next</code>, <code>.velite</code>, tsbuildinfo were all correct. No LICENSE file, also correct for a private repo. No license means all rights reserved by default.</p>\n<h2>My own .gitignore was the problem</h2>\n<p>Then it got to line 7 of my <code>.gitignore</code>.</p>\n<pre><code>/package-lock.json\n</code></pre>\n<p>My own lockfile. At some point I told git to ignore it, deliberately, and then apparently never thought about it again.</p>\n<p>I pushed back on this one, because ignoring a lockfile is a real opinion. Plenty of library maintainers do exactly that, and <a href=\"/blog/i-shipped-a-library-now-what\">I've shipped a library myself</a>, so the instinct has history.</p>\n<p>But this isn't a library, it's a deployed app. Vercel builds this site from the repo, and with no tracked lockfile every build resolves dependencies fresh. <a href=\"/blog/npm-package-json-vs-package-lock-json\">What package-lock.json actually does</a> is pin those resolutions to exact versions, which is what makes deploys reproducible. Without it, a transitive dependency can publish a breaking version and break a deploy with zero code changes on my side. Dependabot also needs the lockfile to open version update PRs at all.</p>\n<p>Extra mess in the same area was pnpm archaeology. A <code>.pnpmfile.cjs</code> in the repo root and a <code>pnpm</code> block in <code>package.json</code>, leftovers from a pnpm phase. Meanwhile <code>.npmrc</code> and <code>package-lock.json</code> say npm is the actual current tool, the lockfile sitting on disk, untracked. Two package managers' worth of config, one of them dead.</p>\n<h2>Dependabot was off. One API call fixed it</h2>\n<p><a href=\"https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts\">Dependabot alerts</a> are GitHub watching my dependencies for known vulnerabilities and telling me when it finds one. They were off, so nothing would have told me when a dependency picked up a CVE. The audit caught it via the API.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">gh</span><span style=\"color:#9ECBFF\"> api</span><span style=\"color:#9ECBFF\"> repos/ManningWorks/lukemanning-site/vulnerability-alerts</span></span></code></pre>\n<p>404. On this endpoint that's just how GitHub says off, which threw me because 404 usually means the repo name is wrong. The docs say 204 means enabled, and this endpoint never returns 200.</p>\n<p>The fix was a PUT.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">gh</span><span style=\"color:#9ECBFF\"> api</span><span style=\"color:#79B8FF\"> -X</span><span style=\"color:#9ECBFF\"> PUT</span><span style=\"color:#9ECBFF\"> repos/ManningWorks/lukemanning-site/vulnerability-alerts</span></span></code></pre>\n<p>Enabling alerts is free even on private repos. One API call.</p>\n<h2>Branch protection needs GitHub Pro on private repos</h2>\n<p>Branch protection on private repos needs GitHub Pro. The API says so directly.</p>\n<pre><code>Upgrade to GitHub Pro or make this repository public\n</code></pre>\n<p>Secret scanning and push protection aren't available on free private repos either. My first thought was that both are moot for a solo dev, but that's only half true. Secret scanning would have real value here, because I'm the most likely person to leak my own secrets. That one's a genuine gap, and closing it costs GitHub Pro money. Push protection, fair enough, there's nobody else's push to protect against. At least now I know it's a plan limit and not a config miss.</p>\n<h2>Forty stale branches and one silent failure</h2>\n<p>The pile was ~40 stale branches, local and remote. Every PR merged on GitHub left its head branch behind, and I'd clearly never gone back to clean up. Mostly merged, long forgotten.</p>\n<p>I set the bar before deleting anything. Only branches proven merged get deleted. Two checks. <code>git branch --merged master</code> and its <code>-r</code> twin for ancestry, plus a cross-check against <code>gh pr list --state merged</code>.</p>\n<p>One branch failed the ancestry check and was still safe to delete, and that's the interesting case.</p>\n<pre><code>8ca8fd1 feat: auto-inject referral boxes... (#26)\n</code></pre>\n<p>That's the output of <code>git log master..feat/referral-link-injection --oneline</code>. One commit, and it wasn't an ancestor of master. But the <code>(#26)</code> suffix is a <a href=\"https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges\">squash-merge</a> fingerprint. My PRs land as squash merges, and squash doesn't carry a branch's commits into master. It forges one fresh commit and stamps \"title (#PR)\" on it.</p>\n<p>Which leaves a question. That stamp belongs on the commit squash forges on master, not on anything sitting at a branch tip. How this one got there, I never fully reconstructed. My best guess is the branch picked up a squash-style commit somewhere along the way, a cherry-pick of one or a reset onto one.</p>\n<p>The cross-check showed the branch actually went out through PR #53, same name, merged, feature live on master. It got reused across PRs at some point and the stale #26 fingerprint never washed off. An orphaned copy, in other words. Tip not in master's history anywhere, changes already live.</p>\n<p>The branch was a husk.</p>\n<p><code>git branch -d</code> would refuse it forever, so it needed <code>-D</code> (force-delete, the shortcut for <code>--delete --force</code>), with the receipt above as justification.</p>\n<p>Then the batch deletion failed halfway through.</p>\n<p>The chain was a long run of <code>git branch -d</code> calls for the proven-merged branches, then <code>git branch -D feat/referral-link-injection</code> at the very end. One of the <code>-d</code> deletions refused for <code>merge/review-publisher-published-posts-into-master</code> because it apparently wasn't merged into master, but it was. The reason is a rule I didn't know existed. From the <a href=\"https://git-scm.com/docs/git-branch\">git man page on <code>-d</code></a>.</p>\n<blockquote>\n<p>The branch must be fully merged in its upstream branch, or in HEAD if no upstream was set.</p>\n</blockquote>\n<p>This branch tracked a differently named remote upstream, <code>origin/review/publisher-published-posts</code>. The refusal explained it clearly enough. Not yet merged to 'refs/remotes/origin/review/publisher-published-posts', even though it is merged to HEAD.</p>\n<p>AI figured it out and helped me clean it up.</p>\n<h2>Sweep before pull doesn't work</h2>\n<p>First instinct for the cleanup was <code>git sweep &#x26;&#x26; git pull</code>. Wrong order, and for a different reason than the <code>-d</code> refusal earlier. The sweep's filter, <code>git branch --merged &#x3C;base></code>, reads whatever base branch it points at. That's a separate check from the <code>-d</code> safety rule above. The filter decides what makes the delete list, and the <code>-d</code> check gives each branch its final veto. If I swept before pulling, my local master didn't yet contain the merge I'd just done on GitHub, so the freshly merged branch never even made the delete list. No error, just a sweep that did nothing useful, so I needed a second sweep anyway. Pull first, then sweep.</p>\n<p>The aliases that landed in <code>~/.gitconfig</code></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">[alias]</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">\tsync</span><span style=\"color:#E1E4E8\"> = </span><span style=\"color:#9ECBFF\">\"!f() { git pull &#x26;&#x26; git sweep; }; f\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">\tsweep</span><span style=\"color:#E1E4E8\"> = </span><span style=\"color:#9ECBFF\">\"!f() { git fetch --prune; b=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null) || b=$(git rev-parse --verify -q main >/dev/null 2>&#x26;1 &#x26;&#x26; echo main || echo master); git branch --merged \\\"</span><span style=\"color:#E1E4E8\">$b\\</span><span style=\"color:#9ECBFF\">\" | grep -vE \\\"</span><span style=\"color:#E1E4E8\">^\\\\*\\</span><span style=\"color:#9ECBFF\">\" | grep -Fvx \\\"</span><span style=\"color:#E1E4E8\">  $b\\</span><span style=\"color:#9ECBFF\">\" | xargs -r git branch -d; }; f\"</span></span></code></pre>\n<p>(<code>-r</code> is a GNU xargs thing, it makes xargs do nothing on empty input. macOS's BSD xargs doesn't have it.)</p>\n<p>The real protection for the drafts is the filter, not the <code>-d</code>. <code>git branch --merged &#x3C;base></code> only lists branches that are ancestors of the base branch, so the unmerged <code>feat/draft-*</code> branches never make the delete list at all. The <code>-d</code> is the backstop. And as that <code>merge/review-publisher...</code> refusal showed, the backstop has its own opinions about upstreams and can refuse mid-sweep. It just refuses safely, which is why it's still <code>-d</code> and not <code>-D</code>. Every sweep run so far: drafts untouched.</p>\n<p>The first cut of the sweep alias had a bug I noticed after: the grep skipped any branch name containing \"master\", so <code>merge/review-publisher-published-posts-into-master</code> would never make the sweep list. It would just sit there forever. That bugged me enough to rewrite it, and the alias above is the rewrite. It resolves the base branch now, <code>origin/HEAD</code> if set, then a <code>main</code>/<code>master</code> fallback, and the greps only skip the current branch and the base itself. The <code>into-master</code> branch gets swept like everything else.</p>\n<p>Then one more API call to set <code>delete_branch_on_merge=true</code>, so merged PR head branches auto-delete on the remote from now on, and the pile can't quietly rebuild itself.</p>",
            "url": "https://lukemanning.ie/blog/repo-audit-gitignore-lockfile",
            "title": "My .gitignore Has Been Ignoring My Lockfile This Whole Time",
            "summary": "<p><strong>TL;DR:</strong> I asked opencode to audit this repo's config against best practices for a private solo repo. Secrets hygiene came back clean. The genuine surprise was line 7 of my own <code>.gitignore</code>, which said <code>/package-lock.json</code>, so my lockfile was never tracked, and Dependabot vulnerability alerts were switched off. Also ~40 stale branches, a loud <code>git branch -d</code> refusal, and the last command in the <code>&#x26;&#x26;</code> chain silently never running because of it. Fixed in commit <code>42cf1f0</code>, plus two git aliases that encode the right cleanup order, <code>git pull &#x26;&#x26; git sweep</code>.</p>\n<hr>\n<p>I asked my agent (<a href=\"/blog/anthropic-open-source-walled-garden-clawdbot-opencode\">opencode</a>) to review this repo's config. The whole ask was to check ManningWorks/lukemanning-site against best practices for a private solo-dev repo and tell me if anything needs changing.</p>\n<p>My honest expectation was a list of green ticks and maybe a nitpick about something I'd decided on purpose. The site builds, Vercel deploys it, nothing's on fire. What could be wrong?</p>\n<p>Most of it was fine. Some of it really wasn't.</p>\n<h2>The parts that were fine</h2>\n<p>Secrets hygiene, the part I actually cared about, came back clean. <code>.env*</code> is gitignored and <code>.env.local</code> holds a real GITHUB_TOKEN and a DEV_TO_API_KEY, untracked. Better still, the audit ran this check</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> log</span><span style=\"color:#79B8FF\"> --all</span><span style=\"color:#79B8FF\"> --oneline</span><span style=\"color:#79B8FF\"> --</span><span style=\"color:#9ECBFF\"> '.env*'</span></span></code></pre>\n<p>Empty output. No env file was ever committed, on any branch, ever. That's the check I'd never have thought to run.</p>\n<p>Ignores for <code>node_modules</code>, <code>.next</code>, <code>.velite</code>, tsbuildinfo were all correct. No LICENSE file, also correct for a private repo. No license means all rights reserved by default.</p>\n<h2>My own .gitignore was the problem</h2>\n<p>Then it got to line 7 of my <code>.gitignore</code>.</p>\n<pre><code>/package-lock.json\n</code></pre>\n<p>My own lockfile. At some point I told git to ignore it, deliberately, and then apparently never thought about it again.</p>\n<p>I pushed back on this one, because ignoring a lockfile is a real opinion. Plenty of library maintainers do exactly that, and <a href=\"/blog/i-shipped-a-library-now-what\">I've shipped a library myself</a>, so the instinct has history.</p>\n<p>But this isn't a library, it's a deployed app. Vercel builds this site from the repo, and with no tracked lockfile every build resolves dependencies fresh. <a href=\"/blog/npm-package-json-vs-package-lock-json\">What package-lock.json actually does</a> is pin those resolutions to exact versions, which is what makes deploys reproducible. Without it, a transitive dependency can publish a breaking version and break a deploy with zero code changes on my side. Dependabot also needs the lockfile to open version update PRs at all.</p>\n<p>Extra mess in the same area was pnpm archaeology. A <code>.pnpmfile.cjs</code> in the repo root and a <code>pnpm</code> block in <code>package.json</code>, leftovers from a pnpm phase. Meanwhile <code>.npmrc</code> and <code>package-lock.json</code> say npm is the actual current tool, the lockfile sitting on disk, untracked. Two package managers' worth of config, one of them dead.</p>\n<h2>Dependabot was off. One API call fixed it</h2>\n<p><a href=\"https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts\">Dependabot alerts</a> are GitHub watching my dependencies for known vulnerabilities and telling me when it finds one. They were off, so nothing would have told me when a dependency picked up a CVE. The audit caught it via the API.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">gh</span><span style=\"color:#9ECBFF\"> api</span><span style=\"color:#9ECBFF\"> repos/ManningWorks/lukemanning-site/vulnerability-alerts</span></span></code></pre>\n<p>404. On this endpoint that's just how GitHub says off, which threw me because 404 usually means the repo name is wrong. The docs say 204 means enabled, and this endpoint never returns 200.</p>\n<p>The fix was a PUT.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">gh</span><span style=\"color:#9ECBFF\"> api</span><span style=\"color:#79B8FF\"> -X</span><span style=\"color:#9ECBFF\"> PUT</span><span style=\"color:#9ECBFF\"> repos/ManningWorks/lukemanning-site/vulnerability-alerts</span></span></code></pre>\n<p>Enabling alerts is free even on private repos. One API call.</p>\n<h2>Branch protection needs GitHub Pro on private repos</h2>\n<p>Branch protection on private repos needs GitHub Pro. The API says so directly.</p>\n<pre><code>Upgrade to GitHub Pro or make this repository public\n</code></pre>\n<p>Secret scanning and push protection aren't available on free private repos either. My first thought was that both are moot for a solo dev, but that's only half true. Secret scanning would have real value here, because I'm the most likely person to leak my own secrets. That one's a genuine gap, and closing it costs GitHub Pro money. Push protection, fair enough, there's nobody else's push to protect against. At least now I know it's a plan limit and not a config miss.</p>\n<h2>Forty stale branches and one silent failure</h2>\n<p>The pile was ~40 stale branches, local and remote. Every PR merged on GitHub left its head branch behind, and I'd clearly never gone back to clean up. Mostly merged, long forgotten.</p>\n<p>I set the bar before deleting anything. Only branches proven merged get deleted. Two checks. <code>git branch --merged master</code> and its <code>-r</code> twin for ancestry, plus a cross-check against <code>gh pr list --state merged</code>.</p>\n<p>One branch failed the ancestry check and was still safe to delete, and that's the interesting case.</p>\n<pre><code>8ca8fd1 feat: auto-inject referral boxes... (#26)\n</code></pre>\n<p>That's the output of <code>git log master..feat/referral-link-injection --oneline</code>. One commit, and it wasn't an ancestor of master. But the <code>(#26)</code> suffix is a <a href=\"https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges\">squash-merge</a> fingerprint. My PRs land as squash merges, and squash doesn't carry a branch's commits into master. It forges one fresh commit and stamps \"title (#PR)\" on it.</p>\n<p>Which leaves a question. That stamp belongs on the commit squash forges on master, not on anything sitting at a branch tip. How this one got there, I never fully reconstructed. My best guess is the branch picked up a squash-style commit somewhere along the way, a cherry-pick of one or a reset onto one.</p>\n<p>The cross-check showed the branch actually went out through PR #53, same name, merged, feature live on master. It got reused across PRs at some point and the stale #26 fingerprint never washed off. An orphaned copy, in other words. Tip not in master's history anywhere, changes already live.</p>\n<p>The branch was a husk.</p>\n<p><code>git branch -d</code> would refuse it forever, so it needed <code>-D</code> (force-delete, the shortcut for <code>--delete --force</code>), with the receipt above as justification.</p>\n<p>Then the batch deletion failed halfway through.</p>\n<p>The chain was a long run of <code>git branch -d</code> calls for the proven-merged branches, then <code>git branch -D feat/referral-link-injection</code> at the very end. One of the <code>-d</code> deletions refused for <code>merge/review-publisher-published-posts-into-master</code> because it apparently wasn't merged into master, but it was. The reason is a rule I didn't know existed. From the <a href=\"https://git-scm.com/docs/git-branch\">git man page on <code>-d</code></a>.</p>\n<blockquote>\n<p>The branch must be fully merged in its upstream branch, or in HEAD if no upstream was set.</p>\n</blockquote>\n<p>This branch tracked a differently named remote upstream, <code>origin/review/publisher-published-posts</code>. The refusal explained it clearly enough. Not yet merged to 'refs/remotes/origin/review/publisher-published-posts', even though it is merged to HEAD.</p>\n<p>AI figured it out and helped me clean it up.</p>\n<h2>Sweep before pull doesn't work</h2>\n<p>First instinct for the cleanup was <code>git sweep &#x26;&#x26; git pull</code>. Wrong order, and for a different reason than the <code>-d</code> refusal earlier. The sweep's filter, <code>git branch --merged &#x3C;base></code>, reads whatever base branch it points at. That's a separate check from the <code>-d</code> safety rule above. The filter decides what makes the delete list, and the <code>-d</code> check gives each branch its final veto. If I swept before pulling, my local master didn't yet contain the merge I'd just done on GitHub, so the freshly merged branch never even made the delete list. No error, just a sweep that did nothing useful, so I needed a second sweep anyway. Pull first, then sweep.</p>\n<p>The aliases that landed in <code>~/.gitconfig</code></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">[alias]</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">\tsync</span><span style=\"color:#E1E4E8\"> = </span><span style=\"color:#9ECBFF\">\"!f() { git pull &#x26;&#x26; git sweep; }; f\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">\tsweep</span><span style=\"color:#E1E4E8\"> = </span><span style=\"color:#9ECBFF\">\"!f() { git fetch --prune; b=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null) || b=$(git rev-parse --verify -q main >/dev/null 2>&#x26;1 &#x26;&#x26; echo main || echo master); git branch --merged \\\"</span><span style=\"color:#E1E4E8\">$b\\</span><span style=\"color:#9ECBFF\">\" | grep -vE \\\"</span><span style=\"color:#E1E4E8\">^\\\\*\\</span><span style=\"color:#9ECBFF\">\" | grep -Fvx \\\"</span><span style=\"color:#E1E4E8\">  $b\\</span><span style=\"color:#9ECBFF\">\" | xargs -r git branch -d; }; f\"</span></span></code></pre>\n<p>(<code>-r</code> is a GNU xargs thing, it makes xargs do nothing on empty input. macOS's BSD xargs doesn't have it.)</p>\n<p>The real protection for the drafts is the filter, not the <code>-d</code>. <code>git branch --merged &#x3C;base></code> only lists branches that are ancestors of the base branch, so the unmerged <code>feat/draft-*</code> branches never make the delete list at all. The <code>-d</code> is the backstop. And as that <code>merge/review-publisher...</code> refusal showed, the backstop has its own opinions about upstreams and can refuse mid-sweep. It just refuses safely, which is why it's still <code>-d</code> and not <code>-D</code>. Every sweep run so far: drafts untouched.</p>\n<p>The first cut of the sweep alias had a bug I noticed after: the grep skipped any branch name containing \"master\", so <code>merge/review-publisher-published-posts-into-master</code> would never make the sweep list. It would just sit there forever. That bugged me enough to rewrite it, and the alias above is the rewrite. It resolves the base branch now, <code>origin/HEAD</code> if set, then a <code>main</code>/<code>master</code> fallback, and the greps only skip the current branch and the base itself. The <code>into-master</code> branch gets swept like everything else.</p>\n<p>Then one more API call to set <code>delete_branch_on_merge=true</code>, so merged PR head branches auto-delete on the remote from now on, and the pile can't quietly rebuild itself.</p>",
            "date_modified": "2026-09-08T00:00:00.000Z",
            "tags": [
                "github"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/i-built-the-projectgrid-it-still-doesnt-solve-the-problem",
            "content_html": "<p>In April I wrote a post called <a href=\"/blog/i-shipped-a-library-now-what\">I Shipped a Component Library. I Have No Idea If Anyone Actually Uses It.</a></p>\n<p>For anyone who hasn't read that post, Projex is an npm library that renders a searchable, filterable grid of project cards from a config file.</p>\n<p>Code review found twelve issues, the review agent asked if I could demo Projex in 60 seconds, I couldn't, and the conclusion was that the code wasn't the blocker. The first-time experience was. The fix I gestured at was a single component: <code>&#x3C;ProjectGrid></code>, one component that accepts the config and handles the data fetching internally. No manual async, no server/client split.</p>\n<p>\"The thing I need is something like a <code>&#x3C;ProjectGrid></code> component.\"</p>\n<p>That was the unlock I named.</p>\n<p>I built it. It shipped in Projex 1.4.0 on August 7th and it's live on npm, where the package went from 755 downloads in April to 143 last month.</p>\n<p>Then I sat down and asked whether it actually did what I said it would.</p>\n<p>It didn't.</p>\n<hr>\n<h2>What I actually shipped</h2>\n<p>Here's the component. Stripped down (the actual export is <code>SmartProjectGrid</code>; the April post called it <code>&#x3C;ProjectGrid></code>, so I'll keep using that name):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">SmartProjectGrid</span><span style=\"color:#B392F0\"> projects</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{projects} </span><span style=\"color:#B392F0\">showSearch</span><span style=\"color:#B392F0\"> showFilters</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  {(</span><span style=\"color:#FFAB70\">project</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> &#x3C;</span><span style=\"color:#79B8FF\">MyCard</span><span style=\"color:#B392F0\"> project</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{project} />}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#79B8FF\">SmartProjectGrid</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>Vs the old way:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#79B8FF\">query</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">setQuery</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> useState</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">''</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#79B8FF\">tags</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">setTags</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> useState</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">string</span><span style=\"color:#E1E4E8\">[]>([])</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> searched</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> useProjectSearch</span><span style=\"color:#E1E4E8\">(projects, query)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> filtered</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> useProjectFilters</span><span style=\"color:#E1E4E8\">(searched, tags)</span></span></code></pre>\n<p>That's the diff. The April post described a component that handles the data fetching internally, no manual async. The shipped one takes <code>projects</code> as a prop. You still write the fetching code yourself. That part of the idea didn't survive.</p>\n<p>The new wrapper saves you maybe twenty lines of boilerplate per page where you want a sortable, filterable, searchable grid. Real savings. Worth shipping.</p>\n<p>It does not solve the demo problem. Not even a little bit.</p>\n<hr>\n<h2>What I thought the demo problem was</h2>\n<p>The April post framed the demo problem like this:</p>\n<blockquote>\n<p>Here's what getting Projex running actually involves right now:</p>\n<ul>\n<li>Install it</li>\n<li>Write async data fetching code</li>\n<li>Understand that GitHub data only loads at build time, not dev time</li>\n<li>Wire up a server component for the data fetch</li>\n<li>Wire up a client component for search</li>\n<li>Deal with the server/client split</li>\n</ul>\n</blockquote>\n<p>The conclusion was that this list is what blocks the 60-second demo. Six steps, server/client split, build-time fetching that doesn't show up in <code>pnpm dev</code>. None of that is the <code>&#x3C;ProjectGrid></code> fix.</p>\n<p>What <code>&#x3C;ProjectGrid></code> actually does is collapse steps 4 through 6. It does not collapse steps 1 through 3. Steps 1 through 3 happen before any code matters. Someone lands on the npm page, reads the README, finds out the data fetching is manual and the GitHub data only loads at build time, and decides whether to install it at all. The wrapper component isn't visible at that moment. They haven't installed it yet. They're trying to imagine what their portfolio would look like.</p>\n<p>If they install it, they get the wrapper. If they don't install it, the wrapper doesn't matter.</p>\n<p>The wrapper helps the people who already decided they wanted it. It does not help the people who are deciding.</p>\n<hr>\n<h2>What I should have framed the problem as</h2>\n<p>The demo problem is not \"the API has too many steps.\"</p>\n<p>The demo problem is \"I cannot show someone what Projex does without them first installing it.\"</p>\n<p>To show someone what Projex does, I need a URL. A page on the internet that renders Projex output with real data, that doesn't require them to clone a repo, that doesn't require them to wire up a Next.js project.</p>\n<p>The npm page is a README. The README is text. Text is not a demo.</p>\n<p>The <code>&#x3C;ProjectGrid></code> component does not solve that. It cannot solve that. No component can solve that. Only a URL solves that.</p>\n<p>I already had one. <a href=\"/projects\">My projects page</a> has been rendering straight from the Projex package since March, pulling its data from this blog's <a href=\"/blog/projex-cli-config-editor\"><code>projex.config.ts</code></a>. The April post links to it as proof the library works. I named a component as the unlock in the same post that linked to the actual unlock.</p>\n<hr>\n<h2>Where that leaves the component</h2>\n<p>In April I named the wrong thing. I named a component because Projex is code, so code felt like the answer. What I needed was something to send someone, and I already had it. The URL existed before the post did.</p>\n<p>If I'd asked in April \"can someone see what this does without installing it?\", the answer would have been yes: my own projects page. I could have gone straight from that question to the Product Hunt launch, the thing the April post said couldn't happen without a demo. I asked \"can I demo this in 60 seconds?\" instead, which framed the answer as code I hadn't written yet. So I waited for code that was never the blocker.</p>\n<p>The component itself is fine, for what it is. It saves maybe twenty lines per page for people who install Projex, and I moved this blog's catalogue onto Projex's own search, filter and sort helpers so the page and the library share one implementation. That's real. It was never the unlock.</p>\n<p>So the next thing is a real demo site: a standalone URL whose whole job is showing what Projex does. This post is the soft launch of that idea. The demo doesn't exist yet. The post does.</p>",
            "url": "https://lukemanning.ie/blog/i-built-the-projectgrid-it-still-doesnt-solve-the-problem",
            "title": "I Built the ProjectGrid. It Still Doesn't Solve the Problem.",
            "summary": "<p>In April I wrote a post called <a href=\"/blog/i-shipped-a-library-now-what\">I Shipped a Component Library. I Have No Idea If Anyone Actually Uses It.</a></p>\n<p>For anyone who hasn't read that post, Projex is an npm library that renders a searchable, filterable grid of project cards from a config file.</p>\n<p>Code review found twelve issues, the review agent asked if I could demo Projex in 60 seconds, I couldn't, and the conclusion was that the code wasn't the blocker. The first-time experience was. The fix I gestured at was a single component: <code>&#x3C;ProjectGrid></code>, one component that accepts the config and handles the data fetching internally. No manual async, no server/client split.</p>\n<p>\"The thing I need is something like a <code>&#x3C;ProjectGrid></code> component.\"</p>\n<p>That was the unlock I named.</p>\n<p>I built it. It shipped in Projex 1.4.0 on August 7th and it's live on npm, where the package went from 755 downloads in April to 143 last month.</p>\n<p>Then I sat down and asked whether it actually did what I said it would.</p>\n<p>It didn't.</p>\n<hr>\n<h2>What I actually shipped</h2>\n<p>Here's the component. Stripped down (the actual export is <code>SmartProjectGrid</code>; the April post called it <code>&#x3C;ProjectGrid></code>, so I'll keep using that name):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">SmartProjectGrid</span><span style=\"color:#B392F0\"> projects</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{projects} </span><span style=\"color:#B392F0\">showSearch</span><span style=\"color:#B392F0\"> showFilters</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  {(</span><span style=\"color:#FFAB70\">project</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> &#x3C;</span><span style=\"color:#79B8FF\">MyCard</span><span style=\"color:#B392F0\"> project</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{project} />}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#79B8FF\">SmartProjectGrid</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>Vs the old way:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#79B8FF\">query</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">setQuery</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> useState</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">''</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#79B8FF\">tags</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">setTags</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> useState</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">string</span><span style=\"color:#E1E4E8\">[]>([])</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> searched</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> useProjectSearch</span><span style=\"color:#E1E4E8\">(projects, query)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> filtered</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> useProjectFilters</span><span style=\"color:#E1E4E8\">(searched, tags)</span></span></code></pre>\n<p>That's the diff. The April post described a component that handles the data fetching internally, no manual async. The shipped one takes <code>projects</code> as a prop. You still write the fetching code yourself. That part of the idea didn't survive.</p>\n<p>The new wrapper saves you maybe twenty lines of boilerplate per page where you want a sortable, filterable, searchable grid. Real savings. Worth shipping.</p>\n<p>It does not solve the demo problem. Not even a little bit.</p>\n<hr>\n<h2>What I thought the demo problem was</h2>\n<p>The April post framed the demo problem like this:</p>\n<blockquote>\n<p>Here's what getting Projex running actually involves right now:</p>\n<ul>\n<li>Install it</li>\n<li>Write async data fetching code</li>\n<li>Understand that GitHub data only loads at build time, not dev time</li>\n<li>Wire up a server component for the data fetch</li>\n<li>Wire up a client component for search</li>\n<li>Deal with the server/client split</li>\n</ul>\n</blockquote>\n<p>The conclusion was that this list is what blocks the 60-second demo. Six steps, server/client split, build-time fetching that doesn't show up in <code>pnpm dev</code>. None of that is the <code>&#x3C;ProjectGrid></code> fix.</p>\n<p>What <code>&#x3C;ProjectGrid></code> actually does is collapse steps 4 through 6. It does not collapse steps 1 through 3. Steps 1 through 3 happen before any code matters. Someone lands on the npm page, reads the README, finds out the data fetching is manual and the GitHub data only loads at build time, and decides whether to install it at all. The wrapper component isn't visible at that moment. They haven't installed it yet. They're trying to imagine what their portfolio would look like.</p>\n<p>If they install it, they get the wrapper. If they don't install it, the wrapper doesn't matter.</p>\n<p>The wrapper helps the people who already decided they wanted it. It does not help the people who are deciding.</p>\n<hr>\n<h2>What I should have framed the problem as</h2>\n<p>The demo problem is not \"the API has too many steps.\"</p>\n<p>The demo problem is \"I cannot show someone what Projex does without them first installing it.\"</p>\n<p>To show someone what Projex does, I need a URL. A page on the internet that renders Projex output with real data, that doesn't require them to clone a repo, that doesn't require them to wire up a Next.js project.</p>\n<p>The npm page is a README. The README is text. Text is not a demo.</p>\n<p>The <code>&#x3C;ProjectGrid></code> component does not solve that. It cannot solve that. No component can solve that. Only a URL solves that.</p>\n<p>I already had one. <a href=\"/projects\">My projects page</a> has been rendering straight from the Projex package since March, pulling its data from this blog's <a href=\"/blog/projex-cli-config-editor\"><code>projex.config.ts</code></a>. The April post links to it as proof the library works. I named a component as the unlock in the same post that linked to the actual unlock.</p>\n<hr>\n<h2>Where that leaves the component</h2>\n<p>In April I named the wrong thing. I named a component because Projex is code, so code felt like the answer. What I needed was something to send someone, and I already had it. The URL existed before the post did.</p>\n<p>If I'd asked in April \"can someone see what this does without installing it?\", the answer would have been yes: my own projects page. I could have gone straight from that question to the Product Hunt launch, the thing the April post said couldn't happen without a demo. I asked \"can I demo this in 60 seconds?\" instead, which framed the answer as code I hadn't written yet. So I waited for code that was never the blocker.</p>\n<p>The component itself is fine, for what it is. It saves maybe twenty lines per page for people who install Projex, and I moved this blog's catalogue onto Projex's own search, filter and sort helpers so the page and the library share one implementation. That's real. It was never the unlock.</p>\n<p>So the next thing is a real demo site: a standalone URL whose whole job is showing what Projex does. This post is the soft launch of that idea. The demo doesn't exist yet. The post does.</p>",
            "date_modified": "2026-08-19T00:00:00.000Z",
            "tags": [
                "projex",
                "reflections"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/i-have-41-posts-i-audited-their-tags-11-survived",
            "content_html": "<p>I have 41 blog posts. They each have tags. Until last week, those tags were whatever I thought made sense when I wrote the post. Some recurred. Most didn't. The rest were categories I wanted to exist, or aspects I wanted to mention.</p>\n<p>I had no system. I had vibes.</p>\n<p>Then I ran the audit that wiped the registry.</p>\n<hr>\n<h2>The system I wanted</h2>\n<p>I wanted four content pillars, four primary niches that described the shape of my blog. Every published post had to declare a pillar. Tags had to map to pillars. A build-time validator had to fail if a published post declared a pillar that no tag supported.</p>\n<p>Four pillars:</p>\n<ul>\n<li><code>ai</code> — AI tooling, agents, OpenCode, Claude Code, agentic coding workflows</li>\n<li><code>homelab</code> — self-hosting, hardware, Unraid, servers, OS setup on machines</li>\n<li><code>rabbit-holes</code> — broad technical pillar: shipping projects + debugging/learning while building things</li>\n<li><code>reflections</code> — non-tech, opinion, career, expanded thoughts</li>\n</ul>\n<p>The pillars themselves were the easy part.</p>\n<p>The hard part was the tags.</p>\n<h2>The audit</h2>\n<p>I ran the audit as a <a href=\"https://github.com/mattpocock/skills/blob/main/skills/engineering/wayfinder/SKILL.md\">wayfinder</a> pass. One decision per tag.</p>\n<p>The mechanics were dull. Every tag lives in a post's frontmatter, so I pulled the frontmatter from all 41 posts, listed every tag with its count, and sorted by that. The agent did the tallying. The judgment calls were all mine.</p>\n<p>Some tags were obviously load-bearing. <code>projex</code> showed up on every post that mentioned my component library. <code>velite</code> showed up on every post about <a href=\"/blog/setting-up-velite-nextjs\">my content pipeline</a>. <code>homelab</code> showed up on the server posts. <code>github</code> showed up on the recovery and the deprecation posts.</p>\n<p>Some tags were obviously decorative. <code>debugging</code> showed up on three posts, all of which were about different kinds of debugging. <code>workflow</code> showed up on five posts, all of which were about different kinds of workflows. <code>ai-assisted-development</code> showed up on one post.</p>\n<p>The first rule was: if it recurs, keep it. That got me a first registry with about 48 tags in it, more than the posts actually used. Some were categories I wanted to exist rather than categories I had. It took reading the five <code>workflow</code> posts back to back to see the problem: they had nothing in common. The rule was measuring the wrong thing.</p>\n<p>A tag earns its place if there's evidence of a series. At least one other post has to anchor it. A tag that could be folded into a broader existing tag gets folded. A tag that's used once and not part of a series dies. The count gets a hearing, but the posts have to actually belong together.</p>\n<p><code>apt</code> died. Used once. Subsumed by <code>ubuntu</code>. No reason to keep it.</p>\n<p><code>github</code> stayed. Multiple posts: <a href=\"/blog/gh-issue-view-failing-ubuntu-deprecation-apt-pin\">the Ubuntu apt deprecation post</a> and <a href=\"/blog/git-corrupt-object-recovery\">the git corruption recovery post</a>. A real series about GitHub-shaped problems.</p>\n<p><code>workflow</code> died. Five uses, zero series. The tag was a context label, not a topic.</p>\n<p><code>claude-code</code> stayed. Two posts already, more coming. Real series.</p>\n<p>The death rate was high. Of 41 tags that appeared across the 41 posts (the equal counts were an accident), 30 died. 11 survived.</p>\n<p>The 11:</p>\n<ul>\n<li><code>ai</code>: <code>opencode</code>, <code>hermes</code>, <code>claude-code</code></li>\n<li><code>homelab</code>: <code>docker</code>, <code>zerowork</code>, <code>obsidian</code></li>\n<li><code>rabbit-holes</code>: <code>projex</code>, <code>velite</code>, <code>nextjs</code>, <code>github</code>, <code>ubuntu</code></li>\n</ul>\n<p>(The registry also carries the pillar names themselves as tags: <code>ai</code>, <code>homelab</code>, <code>reflections</code>. Those aren't series. They're just the pillars.)</p>\n<p>Yes, I could tag any post with the bare pillar name and the validator would wave it through. The validator catches accidents. The coherent-series test is what catches lies. Tagging a post <code>reflections</code> just to get through a build is <code>workflow</code> all over again.</p>\n<p><code>reflections</code> is the only pillar with no series tags. The first genuinely reflective post has to bring its tag through the PR process.</p>\n<h2>Why the death rate was the point</h2>\n<p>A tag is a promise. When I tag a post <code>workflow</code>, if you click this tag, you'll find a series of posts about workflows. The reader lands on a tag page expecting a coherent collection.</p>\n<p>If the tag delivers three posts about three different things, the tag lied. Anyone who clicked it got a pile of nothing. That's on me.</p>\n<p>I keep going back and forth on whether the test is too strict. It's not, really. A tag with no series is just noise on a tag page.</p>\n<p>The 30 tags that died weren't wrong. They were over-promised. They were:</p>\n<ul>\n<li>context labels (<code>debugging</code>, <code>thinking</code>, <code>meta</code>, <code>lessons-learned</code>, <code>retrospective</code>, <code>burnout</code>)</li>\n<li>aspect descriptions (<code>css</code>, <code>styling</code>, <code>build-time</code>, <code>production</code>, <code>ai-assisted-development</code>)</li>\n<li>single-use nouns (<code>apt</code>, <code>devto</code>, <code>router</code>) that fit better as a broader existing tag</li>\n</ul>\n<p>The 11 that survived are the tags I actually write in.</p>\n<h2>What the validator actually does</h2>\n<p>The build-time validator lives in <code>velite.config.js</code>, the config for <a href=\"https://velite.js.org\">Velite</a>, my content pipeline. It runs on every build, for anything published. It checks: the declared <code>pillar</code> of a post must equal the pillar of at least one tag in <code>tags[]</code>. If it doesn't, the build fails.</p>\n<p>This sounds bureaucratic. It isn't. It's the way I keep myself honest.</p>\n<p>Say I write something about what two years of blogging taught me, and I mention in passing that the site runs in Docker. Tag: <code>docker</code>. Declared pillar: <code>reflections</code>. Build dead:</p>\n<pre><code>[content-pillars] post 'what-blogging-taught-me' declares pillar='reflections' but no tag in tags[] maps to that pillar. Either add a tag whose registered pillar is 'reflections' or change the declared pillar.\n</code></pre>\n<p><code>docker</code> is registered as homelab. Nothing on the post maps to reflections. The validator won't let me silently have a post that doesn't belong to any tag series. If a post can't anchor any tag series, it shouldn't be published as a tag-anchored post. It should be published as something else, or it should get a tag that earns its place.</p>\n<p>The validator also changes how I write. When I sit down to write a post and I want to tag it <code>workflow</code>, I have to ask: is this post part of a series I'm willing to commit to? If yes, fine. If no, the tag dies and I pick a real one.</p>\n<p>I don't know if that counts as discipline or just an elaborate way to make my own builds fail. But the vibes never made me stop and ask.</p>\n<h2>The two ADRs that came out of this</h2>\n<p>The whole taxonomy lives in two architecture decision records, under <code>docs/adr/</code> in this site's repo.</p>\n<p>ADR 0001 — the four pillars, the tag registration rule, the coherent-series test. This is the constitution.</p>\n<p>ADR 0002 — the SEO policy. How tag pages render, what metadata they expose, how they relate to pillar pages. This is the policy that turns the constitution into something Google can crawl.</p>\n<p>The wayfinder pass gave me a rule I could encode in the validator.</p>\n<p>I knew what the blog was about. I had been writing it for two years. I didn't have the taxonomy for what it was about until I counted the tags.</p>\n<h2>New tags go through me</h2>\n<p>There will be new tags. New tags require a PR to the registry (<code>src/data/content-pillars.ts</code>) with a one-line rationale that passes the coherent-series test. No agent mediates this. I'm the curator.</p>\n<p>That's the right shape. Tags are mine. The agent can write the post, can audit the existing tags, can build the validator. The decision about whether a new tag earns its place is mine.</p>\n<p>The death rate felt bad while I was doing it. Felt like I was throwing things away, or being too strict.</p>\n<p>Then I read a tag page with three posts that actually belonged together, and I realized what the page would have looked like with seven tags on it, half of which lied. The page would have been unreadable. The reader would have clicked and bounced. The tag would have meant nothing.</p>\n<p>Most of the work in this was deletion. 30 tags out of 41 went. The posts stayed. They're still about what they were about. The surviving tags earned their place.</p>",
            "url": "https://lukemanning.ie/blog/i-have-41-posts-i-audited-their-tags-11-survived",
            "title": "I Have 41 Posts. I Audited Their Tags. 11 Survived.",
            "summary": "<p>I have 41 blog posts. They each have tags. Until last week, those tags were whatever I thought made sense when I wrote the post. Some recurred. Most didn't. The rest were categories I wanted to exist, or aspects I wanted to mention.</p>\n<p>I had no system. I had vibes.</p>\n<p>Then I ran the audit that wiped the registry.</p>\n<hr>\n<h2>The system I wanted</h2>\n<p>I wanted four content pillars, four primary niches that described the shape of my blog. Every published post had to declare a pillar. Tags had to map to pillars. A build-time validator had to fail if a published post declared a pillar that no tag supported.</p>\n<p>Four pillars:</p>\n<ul>\n<li><code>ai</code> — AI tooling, agents, OpenCode, Claude Code, agentic coding workflows</li>\n<li><code>homelab</code> — self-hosting, hardware, Unraid, servers, OS setup on machines</li>\n<li><code>rabbit-holes</code> — broad technical pillar: shipping projects + debugging/learning while building things</li>\n<li><code>reflections</code> — non-tech, opinion, career, expanded thoughts</li>\n</ul>\n<p>The pillars themselves were the easy part.</p>\n<p>The hard part was the tags.</p>\n<h2>The audit</h2>\n<p>I ran the audit as a <a href=\"https://github.com/mattpocock/skills/blob/main/skills/engineering/wayfinder/SKILL.md\">wayfinder</a> pass. One decision per tag.</p>\n<p>The mechanics were dull. Every tag lives in a post's frontmatter, so I pulled the frontmatter from all 41 posts, listed every tag with its count, and sorted by that. The agent did the tallying. The judgment calls were all mine.</p>\n<p>Some tags were obviously load-bearing. <code>projex</code> showed up on every post that mentioned my component library. <code>velite</code> showed up on every post about <a href=\"/blog/setting-up-velite-nextjs\">my content pipeline</a>. <code>homelab</code> showed up on the server posts. <code>github</code> showed up on the recovery and the deprecation posts.</p>\n<p>Some tags were obviously decorative. <code>debugging</code> showed up on three posts, all of which were about different kinds of debugging. <code>workflow</code> showed up on five posts, all of which were about different kinds of workflows. <code>ai-assisted-development</code> showed up on one post.</p>\n<p>The first rule was: if it recurs, keep it. That got me a first registry with about 48 tags in it, more than the posts actually used. Some were categories I wanted to exist rather than categories I had. It took reading the five <code>workflow</code> posts back to back to see the problem: they had nothing in common. The rule was measuring the wrong thing.</p>\n<p>A tag earns its place if there's evidence of a series. At least one other post has to anchor it. A tag that could be folded into a broader existing tag gets folded. A tag that's used once and not part of a series dies. The count gets a hearing, but the posts have to actually belong together.</p>\n<p><code>apt</code> died. Used once. Subsumed by <code>ubuntu</code>. No reason to keep it.</p>\n<p><code>github</code> stayed. Multiple posts: <a href=\"/blog/gh-issue-view-failing-ubuntu-deprecation-apt-pin\">the Ubuntu apt deprecation post</a> and <a href=\"/blog/git-corrupt-object-recovery\">the git corruption recovery post</a>. A real series about GitHub-shaped problems.</p>\n<p><code>workflow</code> died. Five uses, zero series. The tag was a context label, not a topic.</p>\n<p><code>claude-code</code> stayed. Two posts already, more coming. Real series.</p>\n<p>The death rate was high. Of 41 tags that appeared across the 41 posts (the equal counts were an accident), 30 died. 11 survived.</p>\n<p>The 11:</p>\n<ul>\n<li><code>ai</code>: <code>opencode</code>, <code>hermes</code>, <code>claude-code</code></li>\n<li><code>homelab</code>: <code>docker</code>, <code>zerowork</code>, <code>obsidian</code></li>\n<li><code>rabbit-holes</code>: <code>projex</code>, <code>velite</code>, <code>nextjs</code>, <code>github</code>, <code>ubuntu</code></li>\n</ul>\n<p>(The registry also carries the pillar names themselves as tags: <code>ai</code>, <code>homelab</code>, <code>reflections</code>. Those aren't series. They're just the pillars.)</p>\n<p>Yes, I could tag any post with the bare pillar name and the validator would wave it through. The validator catches accidents. The coherent-series test is what catches lies. Tagging a post <code>reflections</code> just to get through a build is <code>workflow</code> all over again.</p>\n<p><code>reflections</code> is the only pillar with no series tags. The first genuinely reflective post has to bring its tag through the PR process.</p>\n<h2>Why the death rate was the point</h2>\n<p>A tag is a promise. When I tag a post <code>workflow</code>, if you click this tag, you'll find a series of posts about workflows. The reader lands on a tag page expecting a coherent collection.</p>\n<p>If the tag delivers three posts about three different things, the tag lied. Anyone who clicked it got a pile of nothing. That's on me.</p>\n<p>I keep going back and forth on whether the test is too strict. It's not, really. A tag with no series is just noise on a tag page.</p>\n<p>The 30 tags that died weren't wrong. They were over-promised. They were:</p>\n<ul>\n<li>context labels (<code>debugging</code>, <code>thinking</code>, <code>meta</code>, <code>lessons-learned</code>, <code>retrospective</code>, <code>burnout</code>)</li>\n<li>aspect descriptions (<code>css</code>, <code>styling</code>, <code>build-time</code>, <code>production</code>, <code>ai-assisted-development</code>)</li>\n<li>single-use nouns (<code>apt</code>, <code>devto</code>, <code>router</code>) that fit better as a broader existing tag</li>\n</ul>\n<p>The 11 that survived are the tags I actually write in.</p>\n<h2>What the validator actually does</h2>\n<p>The build-time validator lives in <code>velite.config.js</code>, the config for <a href=\"https://velite.js.org\">Velite</a>, my content pipeline. It runs on every build, for anything published. It checks: the declared <code>pillar</code> of a post must equal the pillar of at least one tag in <code>tags[]</code>. If it doesn't, the build fails.</p>\n<p>This sounds bureaucratic. It isn't. It's the way I keep myself honest.</p>\n<p>Say I write something about what two years of blogging taught me, and I mention in passing that the site runs in Docker. Tag: <code>docker</code>. Declared pillar: <code>reflections</code>. Build dead:</p>\n<pre><code>[content-pillars] post 'what-blogging-taught-me' declares pillar='reflections' but no tag in tags[] maps to that pillar. Either add a tag whose registered pillar is 'reflections' or change the declared pillar.\n</code></pre>\n<p><code>docker</code> is registered as homelab. Nothing on the post maps to reflections. The validator won't let me silently have a post that doesn't belong to any tag series. If a post can't anchor any tag series, it shouldn't be published as a tag-anchored post. It should be published as something else, or it should get a tag that earns its place.</p>\n<p>The validator also changes how I write. When I sit down to write a post and I want to tag it <code>workflow</code>, I have to ask: is this post part of a series I'm willing to commit to? If yes, fine. If no, the tag dies and I pick a real one.</p>\n<p>I don't know if that counts as discipline or just an elaborate way to make my own builds fail. But the vibes never made me stop and ask.</p>\n<h2>The two ADRs that came out of this</h2>\n<p>The whole taxonomy lives in two architecture decision records, under <code>docs/adr/</code> in this site's repo.</p>\n<p>ADR 0001 — the four pillars, the tag registration rule, the coherent-series test. This is the constitution.</p>\n<p>ADR 0002 — the SEO policy. How tag pages render, what metadata they expose, how they relate to pillar pages. This is the policy that turns the constitution into something Google can crawl.</p>\n<p>The wayfinder pass gave me a rule I could encode in the validator.</p>\n<p>I knew what the blog was about. I had been writing it for two years. I didn't have the taxonomy for what it was about until I counted the tags.</p>\n<h2>New tags go through me</h2>\n<p>There will be new tags. New tags require a PR to the registry (<code>src/data/content-pillars.ts</code>) with a one-line rationale that passes the coherent-series test. No agent mediates this. I'm the curator.</p>\n<p>That's the right shape. Tags are mine. The agent can write the post, can audit the existing tags, can build the validator. The decision about whether a new tag earns its place is mine.</p>\n<p>The death rate felt bad while I was doing it. Felt like I was throwing things away, or being too strict.</p>\n<p>Then I read a tag page with three posts that actually belonged together, and I realized what the page would have looked like with seven tags on it, half of which lied. The page would have been unreadable. The reader would have clicked and bounced. The tag would have meant nothing.</p>\n<p>Most of the work in this was deletion. 30 tags out of 41 went. The posts stayed. They're still about what they were about. The surviving tags earned their place.</p>",
            "date_modified": "2026-08-19T00:00:00.000Z",
            "tags": [
                "velite",
                "nextjs"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/my-agent-closed-most-projex-issues-in-one-round",
            "content_html": "<p>On a Saturday afternoon in August, I told my agent to fix every open issue in the Projex repo and prepare a release. There were ten open. Some were HIGH priority. Some were LOW.</p>\n<p>I went for a walk.</p>\n<p>When I came back, the work had landed across three branches. Each subagent had worked in its own worktree on its own branch. The build was green on each one. The tests were green. The lint was green. The typecheck was green. The release manager subagent had drafted the changelog. Nine of the ten issues were closed. The tenth stayed open — it needed a real union restructure, not a mechanical fix.</p>\n<p>I had not read a single line of the diff yet.</p>\n<h2>What I actually asked for</h2>\n<p>I had a backlog of small things in <a href=\"/blog/building-projex-retrospective\">the Projex repo</a>. Tagged-union cleanups. Type tightening. Documentation gaps. A bug where one of the smart-grid props was a documented prop but a no-op at runtime. A redundancy where two functions with slightly different spellings did the same thing.</p>\n<p>I described this to my main agent. The main agent looked at the issue tracker, saw the labels (<code>bug</code>, <code>enhancement</code>, <code>documentation</code>), grouped them by file area, and <a href=\"/blog/opencode-subagent-permissions-ordering-trap\">dispatched three subagents</a> in parallel.</p>\n<p>One subagent got the package.json + bundling issues. One got the type-system + tagged-union issues. One got the documentation + test-coverage issues. Each one worked in a separate worktree on a separate branch. Each one committed locally and reported back. A fourth subagent, the release manager, handled release prep alongside them: the version bump and the changelog.</p>\n<p>I watched the transcript. Mostly I stayed out of the way. I made tea.</p>\n<h2>What the subagents actually did</h2>\n<p>I saw the dispatch messages. I saw the report-back messages. I did not read the intermediate diffs. The subagents were set up to commit per-issue. Each commit was meant to be independently reviewable.</p>\n<p>A few things I noticed in the report-backs:</p>\n<ul>\n<li>One subagent caught a redundancy I hadn't seen. Two exported functions, <code>normalizeStats</code> and <code>normaliseStats</code>, with the American and British spellings. Both did the same thing. The codebase had drifted to the British spelling in the actual logic. The American spelling was the alias. Both were exported. The subagent deprecated the American one with a JSDoc tag and updated the docs to point at the British spelling.</li>\n<li>One subagent found a related issue while fixing another one. While narrowing the <code>ProjectStats</code> union, it noticed <code>FetchProjectDataResult.commits</code> was using <code>undefined</code> while sibling fields used <code>null</code>. The subagent opened a new issue and included the fix in the same branch.</li>\n<li>One subagent flagged a peer-dependency problem I had been ignoring for two months. The CLI packages (<code>ts-morph</code>, <code>chalk</code>, <code>@inquirer/prompts</code>, <code>commander</code>) were installed by every consumer, even ones who only imported the components. The subagent moved them to optional <code>peerDependencies</code> so consumers importing only components stopped dragging in the CLI bundle.</li>\n</ul>\n<p>None of these were in my original brief. The subagents went past the edges of what I asked for, in the direction of \"things that were obviously wrong in the same file area.\"</p>\n<h2>Where I read the code</h2>\n<p>The first time I read any of the code was after all three subagents finished and opened their PRs. I skimmed the diffs before merging. Not a line-by-line review. A sanity check.</p>\n<p>I was looking for decisions the AI made without asking me. Function names I wouldn't have picked. Behaviour that wasn't in the brief. Edits that touched code outside the file area I asked about. The kind of things a real code review catches, except I was reviewing the decisions, not the code.</p>\n<p>Some of the diffs were four lines. Some were thirty. None of them were complex enough to need a real review. They were tagged-union narrowings, JSDoc additions, dependency relocations. The kind of work where you skim it once, you understand it, you move on.</p>\n<p>If I'd skimmed each PR as it landed, I'd have read the rename with no idea the docs were about to change under it. Reading the batch, I could see the <code>normaliseStats</code> deprecation and the docs update pointing at it in the same sitting. The batch skim was faster than piecemeal would have been.</p>\n<h2>Where I did intervene</h2>\n<p>I didn't push back on any of the code. The three branches each shipped clean. I steered the architecture around the loop, not the code inside it.</p>\n<p>The dispatch went out as three parallel <code>opencode run</code> invocations, not three subagents. I asked for that change when the opencode TUI failed on the first attempt and the right path was to skip the interactive layer. I also argued for splitting the release prep out from the fix work, because trying to do both in the same dispatch kept blocking on the release-manager hitting its timeout before the fix branches landed.</p>\n<h2>What this loop replaced</h2>\n<p>My previous loop was one PR at a time. I'd describe an issue to the agent. The agent would open a PR. I'd skim it, sanity-check the decisions, merge or push back. One issue, one PR, one round of skimming. Repeat.</p>\n<p>For this kind of small mechanical work, that's fine. It works. But the context switching adds up. Each PR is its own session — its own dispatch, its own transcript, its own review pass. The overhead is small per PR and large per backlog.</p>\n<p>The new loop:</p>\n<ol>\n<li>Describe the backlog.</li>\n<li>Wait.</li>\n<li>Skim the batch of PRs.</li>\n<li>Push the release prep.</li>\n</ol>\n<p>The release prep runs alongside the fix work instead of after it. One description covers all of them.</p>\n<p>I want to be careful about what I'm claiming here. I'm not claiming the subagents did better work than the agent would have done one PR at a time. Most of these issues were mechanical. The interesting decisions — which redundancy to deprecate, which naming to standardize — the agent would have surfaced them either way, given the brief. What I'm claiming is that the per-PR overhead moved out of my hands and the interesting decisions stayed in my hands.</p>\n<h2>Reading at the end, not in the middle</h2>\n<p>I did not read the code while it was being written.</p>\n<p>In the old loop, I skimmed each PR after the agent opened it. One PR at a time.</p>\n<p>In the new loop, the skim happened after the writing finished across all three PRs. The subagents were the feedback loop during the work. I was the feedback loop at the end.</p>\n<p>There's a different cost structure. A wrong fix in the old loop was caught in the per-PR skim, or it shipped. A wrong fix in the new loop is caught in the batch skim, or it ships.</p>\n<p>I shipped none of the wrong fixes in this batch. The issues were mechanical and the code area was small. If I'd asked the subagents to redesign the <code>normalise</code> function, I would have read every line, pushed back, rewritten pieces.</p>\n<p>The loop works for the kind of work that has a clear right answer. The loop does not work for the kind of work that needs taste. I haven't found the line yet.</p>\n<p>For this batch, the line was \"moves stuff around, adds JSDoc, narrows types.\" Below the line, I delegated. Above the line, I didn't. The line is in a different place than I would have guessed.</p>\n<h2>Running it again</h2>\n<p>I'm going to run this loop again. On a different repo. On a different kind of work.</p>\n<p>I want to see what happens when the issues aren't mechanical. I want to see where the line moves. I want to see what kinds of work I delegate that I later wish I hadn't, and what kinds I keep that the loop could have handled.</p>\n<p>I'm not going to delegate design decisions. I'm not going to delegate <a href=\"/blog/i-shipped-a-library-now-what\">\"what should this library do\"</a>. I'm going to delegate \"make this library do what it already says it does, correctly.\"</p>\n<p>The batch skim at the end is non-negotiable. That's the part I own.</p>",
            "url": "https://lukemanning.ie/blog/my-agent-closed-most-projex-issues-in-one-round",
            "title": "My Agent Closed Most of the Open Projex Issues in One Round Without Me Reading the Code",
            "summary": "<p>On a Saturday afternoon in August, I told my agent to fix every open issue in the Projex repo and prepare a release. There were ten open. Some were HIGH priority. Some were LOW.</p>\n<p>I went for a walk.</p>\n<p>When I came back, the work had landed across three branches. Each subagent had worked in its own worktree on its own branch. The build was green on each one. The tests were green. The lint was green. The typecheck was green. The release manager subagent had drafted the changelog. Nine of the ten issues were closed. The tenth stayed open — it needed a real union restructure, not a mechanical fix.</p>\n<p>I had not read a single line of the diff yet.</p>\n<h2>What I actually asked for</h2>\n<p>I had a backlog of small things in <a href=\"/blog/building-projex-retrospective\">the Projex repo</a>. Tagged-union cleanups. Type tightening. Documentation gaps. A bug where one of the smart-grid props was a documented prop but a no-op at runtime. A redundancy where two functions with slightly different spellings did the same thing.</p>\n<p>I described this to my main agent. The main agent looked at the issue tracker, saw the labels (<code>bug</code>, <code>enhancement</code>, <code>documentation</code>), grouped them by file area, and <a href=\"/blog/opencode-subagent-permissions-ordering-trap\">dispatched three subagents</a> in parallel.</p>\n<p>One subagent got the package.json + bundling issues. One got the type-system + tagged-union issues. One got the documentation + test-coverage issues. Each one worked in a separate worktree on a separate branch. Each one committed locally and reported back. A fourth subagent, the release manager, handled release prep alongside them: the version bump and the changelog.</p>\n<p>I watched the transcript. Mostly I stayed out of the way. I made tea.</p>\n<h2>What the subagents actually did</h2>\n<p>I saw the dispatch messages. I saw the report-back messages. I did not read the intermediate diffs. The subagents were set up to commit per-issue. Each commit was meant to be independently reviewable.</p>\n<p>A few things I noticed in the report-backs:</p>\n<ul>\n<li>One subagent caught a redundancy I hadn't seen. Two exported functions, <code>normalizeStats</code> and <code>normaliseStats</code>, with the American and British spellings. Both did the same thing. The codebase had drifted to the British spelling in the actual logic. The American spelling was the alias. Both were exported. The subagent deprecated the American one with a JSDoc tag and updated the docs to point at the British spelling.</li>\n<li>One subagent found a related issue while fixing another one. While narrowing the <code>ProjectStats</code> union, it noticed <code>FetchProjectDataResult.commits</code> was using <code>undefined</code> while sibling fields used <code>null</code>. The subagent opened a new issue and included the fix in the same branch.</li>\n<li>One subagent flagged a peer-dependency problem I had been ignoring for two months. The CLI packages (<code>ts-morph</code>, <code>chalk</code>, <code>@inquirer/prompts</code>, <code>commander</code>) were installed by every consumer, even ones who only imported the components. The subagent moved them to optional <code>peerDependencies</code> so consumers importing only components stopped dragging in the CLI bundle.</li>\n</ul>\n<p>None of these were in my original brief. The subagents went past the edges of what I asked for, in the direction of \"things that were obviously wrong in the same file area.\"</p>\n<h2>Where I read the code</h2>\n<p>The first time I read any of the code was after all three subagents finished and opened their PRs. I skimmed the diffs before merging. Not a line-by-line review. A sanity check.</p>\n<p>I was looking for decisions the AI made without asking me. Function names I wouldn't have picked. Behaviour that wasn't in the brief. Edits that touched code outside the file area I asked about. The kind of things a real code review catches, except I was reviewing the decisions, not the code.</p>\n<p>Some of the diffs were four lines. Some were thirty. None of them were complex enough to need a real review. They were tagged-union narrowings, JSDoc additions, dependency relocations. The kind of work where you skim it once, you understand it, you move on.</p>\n<p>If I'd skimmed each PR as it landed, I'd have read the rename with no idea the docs were about to change under it. Reading the batch, I could see the <code>normaliseStats</code> deprecation and the docs update pointing at it in the same sitting. The batch skim was faster than piecemeal would have been.</p>\n<h2>Where I did intervene</h2>\n<p>I didn't push back on any of the code. The three branches each shipped clean. I steered the architecture around the loop, not the code inside it.</p>\n<p>The dispatch went out as three parallel <code>opencode run</code> invocations, not three subagents. I asked for that change when the opencode TUI failed on the first attempt and the right path was to skip the interactive layer. I also argued for splitting the release prep out from the fix work, because trying to do both in the same dispatch kept blocking on the release-manager hitting its timeout before the fix branches landed.</p>\n<h2>What this loop replaced</h2>\n<p>My previous loop was one PR at a time. I'd describe an issue to the agent. The agent would open a PR. I'd skim it, sanity-check the decisions, merge or push back. One issue, one PR, one round of skimming. Repeat.</p>\n<p>For this kind of small mechanical work, that's fine. It works. But the context switching adds up. Each PR is its own session — its own dispatch, its own transcript, its own review pass. The overhead is small per PR and large per backlog.</p>\n<p>The new loop:</p>\n<ol>\n<li>Describe the backlog.</li>\n<li>Wait.</li>\n<li>Skim the batch of PRs.</li>\n<li>Push the release prep.</li>\n</ol>\n<p>The release prep runs alongside the fix work instead of after it. One description covers all of them.</p>\n<p>I want to be careful about what I'm claiming here. I'm not claiming the subagents did better work than the agent would have done one PR at a time. Most of these issues were mechanical. The interesting decisions — which redundancy to deprecate, which naming to standardize — the agent would have surfaced them either way, given the brief. What I'm claiming is that the per-PR overhead moved out of my hands and the interesting decisions stayed in my hands.</p>\n<h2>Reading at the end, not in the middle</h2>\n<p>I did not read the code while it was being written.</p>\n<p>In the old loop, I skimmed each PR after the agent opened it. One PR at a time.</p>\n<p>In the new loop, the skim happened after the writing finished across all three PRs. The subagents were the feedback loop during the work. I was the feedback loop at the end.</p>\n<p>There's a different cost structure. A wrong fix in the old loop was caught in the per-PR skim, or it shipped. A wrong fix in the new loop is caught in the batch skim, or it ships.</p>\n<p>I shipped none of the wrong fixes in this batch. The issues were mechanical and the code area was small. If I'd asked the subagents to redesign the <code>normalise</code> function, I would have read every line, pushed back, rewritten pieces.</p>\n<p>The loop works for the kind of work that has a clear right answer. The loop does not work for the kind of work that needs taste. I haven't found the line yet.</p>\n<p>For this batch, the line was \"moves stuff around, adds JSDoc, narrows types.\" Below the line, I delegated. Above the line, I didn't. The line is in a different place than I would have guessed.</p>\n<h2>Running it again</h2>\n<p>I'm going to run this loop again. On a different repo. On a different kind of work.</p>\n<p>I want to see what happens when the issues aren't mechanical. I want to see where the line moves. I want to see what kinds of work I delegate that I later wish I hadn't, and what kinds I keep that the loop could have handled.</p>\n<p>I'm not going to delegate design decisions. I'm not going to delegate <a href=\"/blog/i-shipped-a-library-now-what\">\"what should this library do\"</a>. I'm going to delegate \"make this library do what it already says it does, correctly.\"</p>\n<p>The batch skim at the end is non-negotiable. That's the part I own.</p>",
            "date_modified": "2026-08-19T00:00:00.000Z",
            "tags": [
                "ai",
                "opencode",
                "projex",
                "github"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/gh-issue-view-failing-ubuntu-deprecation-apt-pin",
            "content_html": "<p><strong>TL;DR:</strong> Ubuntu 24.04's packaged gh (2.45.0) is broken. GitHub sunset Projects (classic), and 2.45.0 still requests that field in its GraphQL query, so what reads like a deprecation warning is actually a hard error that kills every <code>gh issue view</code> and <code>gh pr view</code>. Three problems stacked: the warning was the error, GitHub's apt repo lives at <code>/packages</code> not <code>/apt</code>, and Ubuntu ESM out-pins GitHub at priority 510 vs 500. Fixed on 2.97.0.</p>\n<hr>\n<p>I was trying to read an issue on this site's repo:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">gh</span><span style=\"color:#9ECBFF\"> issue</span><span style=\"color:#9ECBFF\"> view</span><span style=\"color:#79B8FF\"> 28</span><span style=\"color:#79B8FF\"> --repo</span><span style=\"color:#9ECBFF\"> ManningWorks/lukemanning-site</span><span style=\"color:#79B8FF\"> --comments</span></span></code></pre>\n<p>What came back was this:</p>\n<pre><code>GraphQL: Projects (classic) is being deprecated in favor of the new Projects\nexperience, see: https://github.blog/changelog/2024-05-23-sunset-notice-projects-classic/.\n(repository.issue.projectCards)\n</code></pre>\n<p>I asked my AI assistant (<a href=\"/blog/anthropic-open-source-walled-garden-clawdbot-opencode\">opencode</a>) what it meant. It told me it was just a deprecation warning, not an error, and the command works fine.</p>\n<p>Except it kept telling me the command had failed. So I pushed back: \"You keep telling me that command fails though.\" It ran the command, and a different error surfaced. This one was mine. In my message I'd written <code>gh issue view 28 --repo --comments</code> with no repo value, assuming it would fill in the blanks. It ran exactly what I'd written. <code>--repo</code> with no value swallowed <code>--comments</code>:</p>\n<pre><code>expected the \"[HOST/]OWNER/REPO\" format, got \"--comments\"\n</code></pre>\n<p>Fine. My shorthand, my missing value. With the repo filled in, the original failure came straight back:</p>\n<p>Exit code 1. Zero bytes on stdout. The deprecation message, alone, on stderr.</p>\n<p>That's when it clicked. The \"warning\" was the failure. The command produced nothing except a note formatted like a heads-up about the future.</p>\n<p>The assistant then decided the deprecation message was a red herring that would only appear once the repo resolved. Also wrong. Twice in one conversation.</p>\n<h2>Problem One: The Warning Was the Error</h2>\n<p>Ubuntu ships gh 2.45.0. That version requests <code>repository.issue.projectCards</code> as part of its GraphQL query for issue views. GitHub sunset Projects (classic) in May 2024 and now returns that field as a hard GraphQL error. A hard error kills the whole query, so every <code>gh issue view</code> and <code>gh pr view</code> on 2.45.x fails with output that reads like a deprecation notice.</p>\n<p>This is a known thing: <a href=\"https://github.com/cli/cli/issues/11992\">cli/cli#11992</a>. The maintainers' answer is to stop using distro packages and install from GitHub's official repo. GitHub's <a href=\"https://github.com/cli/cli/blob/trunk/docs/install_linux.md\">Linux install docs</a> now say it outright: as of November 2025 they strongly recommend the official Debian packages, since the community-distributed 2.45.x and 2.46.x are broken against current GitHub APIs.</p>\n<p>So Ubuntu's gh is broken in a way GitHub officially acknowledges. Weirdly comforting.</p>\n<h2>The Workaround</h2>\n<p><code>gh api</code> goes through REST, not GraphQL, so it still worked:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">gh</span><span style=\"color:#9ECBFF\"> api</span><span style=\"color:#9ECBFF\"> repos/ManningWorks/lukemanning-site/issues/28</span><span style=\"color:#79B8FF\"> --jq</span><span style=\"color:#9ECBFF\"> '.title, .body'</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">gh</span><span style=\"color:#9ECBFF\"> api</span><span style=\"color:#9ECBFF\"> repos/ManningWorks/lukemanning-site/issues/28/comments</span><span style=\"color:#79B8FF\"> --jq</span><span style=\"color:#9ECBFF\"> '.[] | .author.login, .body'</span></span></code></pre>\n<p>Clunky, but it let me read the issue while I sorted out the upgrade.</p>\n<h2>Problem Two: The Wrong Repo URL</h2>\n<p>The assistant set up GitHub's apt repo from memory: <code>https://cli.github.com/apt</code>.</p>\n<pre><code>The repository 'https://cli.github.com/apt stable Release' does not have a Release file.\n</code></pre>\n<p>That's a 404. The repo isn't there. I enjoyed this one. The assistant guessed a URL instead of checking the docs, which is the exact thing it tells me not to do constantly.</p>\n<p>GitHub's own <a href=\"https://github.com/cli/cli/blob/trunk/docs/install_linux.md\">install docs</a> say it's <code>https://cli.github.com/packages</code>. The assistant followed them this time, keyring staged through /tmp since wget can't write to <code>/etc/apt/keyrings</code> as me:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">wget</span><span style=\"color:#79B8FF\"> -nv</span><span style=\"color:#79B8FF\"> -O</span><span style=\"color:#9ECBFF\"> /tmp/githubcli-archive-keyring.gpg</span><span style=\"color:#9ECBFF\"> https://cli.github.com/packages/githubcli-archive-keyring.gpg</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">cat</span><span style=\"color:#9ECBFF\"> /tmp/githubcli-archive-keyring.gpg</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> sudo</span><span style=\"color:#9ECBFF\"> tee</span><span style=\"color:#9ECBFF\"> /etc/apt/keyrings/githubcli-archive-keyring.gpg</span><span style=\"color:#F97583\"> ></span><span style=\"color:#9ECBFF\"> /dev/null</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">sudo</span><span style=\"color:#9ECBFF\"> chmod</span><span style=\"color:#9ECBFF\"> go+r</span><span style=\"color:#9ECBFF\"> /etc/apt/keyrings/githubcli-archive-keyring.gpg</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"deb [arch=amd64 signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> sudo</span><span style=\"color:#9ECBFF\"> tee</span><span style=\"color:#9ECBFF\"> /etc/apt/sources.list.d/github-cli.list</span></span></code></pre>\n<p>Repo added, <code>apt update</code> clean, <code>apt install gh</code>... nothing. Still 2.45.0. Three failure layers, for anyone counting.</p>\n<h2>Problem Three: ESM Refuses to Lose</h2>\n<p><code>apt-cache policy gh</code> explained it:</p>\n<pre><code>gh:\n  Installed: 2.45.0-1ubuntu0.3+esm3\n  Candidate: 2.45.0-1ubuntu0.3+esm3\n  Version table:\n     2.97.0 500\n        500 https://cli.github.com/packages stable/main amd64 Packages\n *** 2.45.0-1ubuntu0.3+esm3 510\n        510 https://esm.ubuntu.com/apps/ubuntu noble-apps-security/main amd64 Packages\n</code></pre>\n<p>GitHub's repo offers 2.97.0 at priority 500, the apt default. Ubuntu ESM pins its version at 510. ESM is Expanded Security Maintenance, the security-update stream that comes with Ubuntu Pro, and this machine is Ubuntu 24.04 (noble), amd64, with Pro enabled. Higher priority wins the candidate slot, even though ESM's 2.45.0 is ancient.</p>\n<p>An explicit version bypasses the priority contest:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">sudo</span><span style=\"color:#9ECBFF\"> apt</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#9ECBFF\"> gh=</span><span style=\"color:#79B8FF\">2.97.0</span></span></code></pre>\n<p>That worked. <code>gh --version</code> reported 2.97.0, and <code>gh issue view 28 --comments</code> (from the repo root, so no <code>--repo</code> needed) finally exited 0 with full output.</p>\n<h2>The Pin, Validated</h2>\n<p>The explicit install worked, but the candidate was still ESM's 2.45.0. The assistant said I needed a pin file so GitHub's repo wins going forward. Before touching anything in <code>/etc/apt/preferences.d/</code>, I asked it to validate that. Do Ubuntu or GitHub actually document this, or is it forum folklore?</p>\n<p>Three primary sources:</p>\n<ol>\n<li>\n<p><strong>My own machine.</strong> <code>/etc/apt/preferences.d/ubuntu-pro-esm-apps</code>, shipped by ubuntu-pro-client, pins <code>release o=UbuntuESMApps</code> at 510. The file's own comment says it's deliberate: when ESM is enabled, ESM packages are preferred over non-ESM ones.</p>\n</li>\n<li>\n<p><strong><code>man apt_preferences</code>.</strong> Priorities from 500 to 990 install a version unless a newer one is already installed. 1000 or higher is required to downgrade. So ESM can't drag me back to 2.45.0, but a plain <code>apt upgrade</code> would never pick up a future GitHub release either. I'd be frozen on 2.97.0.</p>\n</li>\n<li>\n<p><strong>Canonical's tracker and docs.</strong> <a href=\"https://github.com/canonical/ubuntu-pro-client/issues/3330\">ubuntu-pro-client issue #3330</a> is someone hitting exactly this: a newer fish from a PPA, ignored in favour of ESM's. A Canonical maintainer closed it as intended, because cloud customers didn't want ESM security fixes silently reverted. The stated remedy:</p>\n<blockquote>\n<p>manually configure apt... by creating a preferences file pinning the PPA to a higher version than ESM.</p>\n</blockquote>\n<p>Canonical's Pro Client docs say close to the same: give the third-party repo at least 510.</p>\n</li>\n</ol>\n<p>So the pin is real, and documented. Good.</p>\n<h2>Why 600 and Not 510</h2>\n<p>Those two sources don't quite agree, it turns out. The docs allow equal: at least 510. The issue thread says higher than ESM. And what apt actually does when two repos sit at the same priority, I couldn't find in <code>man apt_preferences</code>. Maybe there's a tie-break rule somewhere. I didn't find it.</p>\n<p>I let the assistant pick the number in the end, within the bounds the man page had already given me. It went with 600: unconditionally \"GitHub's repo is authoritative for gh\", above both suggested floors, and below 1000 so nothing can ever downgrade me.</p>\n<p>Works for me. 510 would satisfy the docs, but a pin that only ties the thing it's fighting isn't a config I want to reason about again in six months.</p>\n<p><code>/etc/apt/preferences.d/gh</code>:</p>\n<pre><code># gh: prefer GitHub official apt repo over Ubuntu ESM pin (510).\n# ESM gh 2.45.x is broken vs current GitHub APIs - see cli/cli#11992.\n# Canonical documents this remedy: https://documentation.ubuntu.com/pro-client/en/latest/explanations/about_esm/\nPackage: gh\nPin: origin cli.github.com\nPin-Priority: 600\n</code></pre>\n<p>Then I verified with <code>apt-cache policy gh</code>, which now shows <code>*** 2.97.0 600</code>. I checked because the assistant had flagged the failure mode to watch for: if the origin in the pin doesn't match the host in the sources list, there's no error. The pin just doesn't apply.</p>\n<h2>What Stuck</h2>\n<p>Any one of these three alone would have been a <a href=\"/blog/git-corrupt-object-recovery\">proper rabbit hole</a>.</p>\n<p>The assistant was confidently wrong twice, and that's the part I keep relearning. First that the message was harmless, then that it was a red herring. Both times, actually running the command with stdout and stderr separated settled it in seconds. Asking an AI what a command does is <a href=\"/blog/premature-optimization-multi-agent-prompts\">asking it to hallucinate from training data</a>. The apt URL guess showed exactly how that goes.</p>\n<p>gh works now. Which is nice, because I originally just wanted to read one issue.</p>",
            "url": "https://lukemanning.ie/blog/gh-issue-view-failing-ubuntu-deprecation-apt-pin",
            "title": "gh issue view Kept Failing Because Ubuntu's ESM Outranks GitHub's Repo",
            "summary": "<p><strong>TL;DR:</strong> Ubuntu 24.04's packaged gh (2.45.0) is broken. GitHub sunset Projects (classic), and 2.45.0 still requests that field in its GraphQL query, so what reads like a deprecation warning is actually a hard error that kills every <code>gh issue view</code> and <code>gh pr view</code>. Three problems stacked: the warning was the error, GitHub's apt repo lives at <code>/packages</code> not <code>/apt</code>, and Ubuntu ESM out-pins GitHub at priority 510 vs 500. Fixed on 2.97.0.</p>\n<hr>\n<p>I was trying to read an issue on this site's repo:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">gh</span><span style=\"color:#9ECBFF\"> issue</span><span style=\"color:#9ECBFF\"> view</span><span style=\"color:#79B8FF\"> 28</span><span style=\"color:#79B8FF\"> --repo</span><span style=\"color:#9ECBFF\"> ManningWorks/lukemanning-site</span><span style=\"color:#79B8FF\"> --comments</span></span></code></pre>\n<p>What came back was this:</p>\n<pre><code>GraphQL: Projects (classic) is being deprecated in favor of the new Projects\nexperience, see: https://github.blog/changelog/2024-05-23-sunset-notice-projects-classic/.\n(repository.issue.projectCards)\n</code></pre>\n<p>I asked my AI assistant (<a href=\"/blog/anthropic-open-source-walled-garden-clawdbot-opencode\">opencode</a>) what it meant. It told me it was just a deprecation warning, not an error, and the command works fine.</p>\n<p>Except it kept telling me the command had failed. So I pushed back: \"You keep telling me that command fails though.\" It ran the command, and a different error surfaced. This one was mine. In my message I'd written <code>gh issue view 28 --repo --comments</code> with no repo value, assuming it would fill in the blanks. It ran exactly what I'd written. <code>--repo</code> with no value swallowed <code>--comments</code>:</p>\n<pre><code>expected the \"[HOST/]OWNER/REPO\" format, got \"--comments\"\n</code></pre>\n<p>Fine. My shorthand, my missing value. With the repo filled in, the original failure came straight back:</p>\n<p>Exit code 1. Zero bytes on stdout. The deprecation message, alone, on stderr.</p>\n<p>That's when it clicked. The \"warning\" was the failure. The command produced nothing except a note formatted like a heads-up about the future.</p>\n<p>The assistant then decided the deprecation message was a red herring that would only appear once the repo resolved. Also wrong. Twice in one conversation.</p>\n<h2>Problem One: The Warning Was the Error</h2>\n<p>Ubuntu ships gh 2.45.0. That version requests <code>repository.issue.projectCards</code> as part of its GraphQL query for issue views. GitHub sunset Projects (classic) in May 2024 and now returns that field as a hard GraphQL error. A hard error kills the whole query, so every <code>gh issue view</code> and <code>gh pr view</code> on 2.45.x fails with output that reads like a deprecation notice.</p>\n<p>This is a known thing: <a href=\"https://github.com/cli/cli/issues/11992\">cli/cli#11992</a>. The maintainers' answer is to stop using distro packages and install from GitHub's official repo. GitHub's <a href=\"https://github.com/cli/cli/blob/trunk/docs/install_linux.md\">Linux install docs</a> now say it outright: as of November 2025 they strongly recommend the official Debian packages, since the community-distributed 2.45.x and 2.46.x are broken against current GitHub APIs.</p>\n<p>So Ubuntu's gh is broken in a way GitHub officially acknowledges. Weirdly comforting.</p>\n<h2>The Workaround</h2>\n<p><code>gh api</code> goes through REST, not GraphQL, so it still worked:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">gh</span><span style=\"color:#9ECBFF\"> api</span><span style=\"color:#9ECBFF\"> repos/ManningWorks/lukemanning-site/issues/28</span><span style=\"color:#79B8FF\"> --jq</span><span style=\"color:#9ECBFF\"> '.title, .body'</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">gh</span><span style=\"color:#9ECBFF\"> api</span><span style=\"color:#9ECBFF\"> repos/ManningWorks/lukemanning-site/issues/28/comments</span><span style=\"color:#79B8FF\"> --jq</span><span style=\"color:#9ECBFF\"> '.[] | .author.login, .body'</span></span></code></pre>\n<p>Clunky, but it let me read the issue while I sorted out the upgrade.</p>\n<h2>Problem Two: The Wrong Repo URL</h2>\n<p>The assistant set up GitHub's apt repo from memory: <code>https://cli.github.com/apt</code>.</p>\n<pre><code>The repository 'https://cli.github.com/apt stable Release' does not have a Release file.\n</code></pre>\n<p>That's a 404. The repo isn't there. I enjoyed this one. The assistant guessed a URL instead of checking the docs, which is the exact thing it tells me not to do constantly.</p>\n<p>GitHub's own <a href=\"https://github.com/cli/cli/blob/trunk/docs/install_linux.md\">install docs</a> say it's <code>https://cli.github.com/packages</code>. The assistant followed them this time, keyring staged through /tmp since wget can't write to <code>/etc/apt/keyrings</code> as me:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">wget</span><span style=\"color:#79B8FF\"> -nv</span><span style=\"color:#79B8FF\"> -O</span><span style=\"color:#9ECBFF\"> /tmp/githubcli-archive-keyring.gpg</span><span style=\"color:#9ECBFF\"> https://cli.github.com/packages/githubcli-archive-keyring.gpg</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">cat</span><span style=\"color:#9ECBFF\"> /tmp/githubcli-archive-keyring.gpg</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> sudo</span><span style=\"color:#9ECBFF\"> tee</span><span style=\"color:#9ECBFF\"> /etc/apt/keyrings/githubcli-archive-keyring.gpg</span><span style=\"color:#F97583\"> ></span><span style=\"color:#9ECBFF\"> /dev/null</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">sudo</span><span style=\"color:#9ECBFF\"> chmod</span><span style=\"color:#9ECBFF\"> go+r</span><span style=\"color:#9ECBFF\"> /etc/apt/keyrings/githubcli-archive-keyring.gpg</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"deb [arch=amd64 signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> sudo</span><span style=\"color:#9ECBFF\"> tee</span><span style=\"color:#9ECBFF\"> /etc/apt/sources.list.d/github-cli.list</span></span></code></pre>\n<p>Repo added, <code>apt update</code> clean, <code>apt install gh</code>... nothing. Still 2.45.0. Three failure layers, for anyone counting.</p>\n<h2>Problem Three: ESM Refuses to Lose</h2>\n<p><code>apt-cache policy gh</code> explained it:</p>\n<pre><code>gh:\n  Installed: 2.45.0-1ubuntu0.3+esm3\n  Candidate: 2.45.0-1ubuntu0.3+esm3\n  Version table:\n     2.97.0 500\n        500 https://cli.github.com/packages stable/main amd64 Packages\n *** 2.45.0-1ubuntu0.3+esm3 510\n        510 https://esm.ubuntu.com/apps/ubuntu noble-apps-security/main amd64 Packages\n</code></pre>\n<p>GitHub's repo offers 2.97.0 at priority 500, the apt default. Ubuntu ESM pins its version at 510. ESM is Expanded Security Maintenance, the security-update stream that comes with Ubuntu Pro, and this machine is Ubuntu 24.04 (noble), amd64, with Pro enabled. Higher priority wins the candidate slot, even though ESM's 2.45.0 is ancient.</p>\n<p>An explicit version bypasses the priority contest:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">sudo</span><span style=\"color:#9ECBFF\"> apt</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#9ECBFF\"> gh=</span><span style=\"color:#79B8FF\">2.97.0</span></span></code></pre>\n<p>That worked. <code>gh --version</code> reported 2.97.0, and <code>gh issue view 28 --comments</code> (from the repo root, so no <code>--repo</code> needed) finally exited 0 with full output.</p>\n<h2>The Pin, Validated</h2>\n<p>The explicit install worked, but the candidate was still ESM's 2.45.0. The assistant said I needed a pin file so GitHub's repo wins going forward. Before touching anything in <code>/etc/apt/preferences.d/</code>, I asked it to validate that. Do Ubuntu or GitHub actually document this, or is it forum folklore?</p>\n<p>Three primary sources:</p>\n<ol>\n<li>\n<p><strong>My own machine.</strong> <code>/etc/apt/preferences.d/ubuntu-pro-esm-apps</code>, shipped by ubuntu-pro-client, pins <code>release o=UbuntuESMApps</code> at 510. The file's own comment says it's deliberate: when ESM is enabled, ESM packages are preferred over non-ESM ones.</p>\n</li>\n<li>\n<p><strong><code>man apt_preferences</code>.</strong> Priorities from 500 to 990 install a version unless a newer one is already installed. 1000 or higher is required to downgrade. So ESM can't drag me back to 2.45.0, but a plain <code>apt upgrade</code> would never pick up a future GitHub release either. I'd be frozen on 2.97.0.</p>\n</li>\n<li>\n<p><strong>Canonical's tracker and docs.</strong> <a href=\"https://github.com/canonical/ubuntu-pro-client/issues/3330\">ubuntu-pro-client issue #3330</a> is someone hitting exactly this: a newer fish from a PPA, ignored in favour of ESM's. A Canonical maintainer closed it as intended, because cloud customers didn't want ESM security fixes silently reverted. The stated remedy:</p>\n<blockquote>\n<p>manually configure apt... by creating a preferences file pinning the PPA to a higher version than ESM.</p>\n</blockquote>\n<p>Canonical's Pro Client docs say close to the same: give the third-party repo at least 510.</p>\n</li>\n</ol>\n<p>So the pin is real, and documented. Good.</p>\n<h2>Why 600 and Not 510</h2>\n<p>Those two sources don't quite agree, it turns out. The docs allow equal: at least 510. The issue thread says higher than ESM. And what apt actually does when two repos sit at the same priority, I couldn't find in <code>man apt_preferences</code>. Maybe there's a tie-break rule somewhere. I didn't find it.</p>\n<p>I let the assistant pick the number in the end, within the bounds the man page had already given me. It went with 600: unconditionally \"GitHub's repo is authoritative for gh\", above both suggested floors, and below 1000 so nothing can ever downgrade me.</p>\n<p>Works for me. 510 would satisfy the docs, but a pin that only ties the thing it's fighting isn't a config I want to reason about again in six months.</p>\n<p><code>/etc/apt/preferences.d/gh</code>:</p>\n<pre><code># gh: prefer GitHub official apt repo over Ubuntu ESM pin (510).\n# ESM gh 2.45.x is broken vs current GitHub APIs - see cli/cli#11992.\n# Canonical documents this remedy: https://documentation.ubuntu.com/pro-client/en/latest/explanations/about_esm/\nPackage: gh\nPin: origin cli.github.com\nPin-Priority: 600\n</code></pre>\n<p>Then I verified with <code>apt-cache policy gh</code>, which now shows <code>*** 2.97.0 600</code>. I checked because the assistant had flagged the failure mode to watch for: if the origin in the pin doesn't match the host in the sources list, there's no error. The pin just doesn't apply.</p>\n<h2>What Stuck</h2>\n<p>Any one of these three alone would have been a <a href=\"/blog/git-corrupt-object-recovery\">proper rabbit hole</a>.</p>\n<p>The assistant was confidently wrong twice, and that's the part I keep relearning. First that the message was harmless, then that it was a red herring. Both times, actually running the command with stdout and stderr separated settled it in seconds. Asking an AI what a command does is <a href=\"/blog/premature-optimization-multi-agent-prompts\">asking it to hallucinate from training data</a>. The apt URL guess showed exactly how that goes.</p>\n<p>gh works now. Which is nice, because I originally just wanted to read one issue.</p>",
            "date_modified": "2026-08-14T00:00:00.000Z",
            "tags": [
                "github",
                "ubuntu"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/hermes-profiles-to-docker-part-two",
            "content_html": "<p>This is a follow-up to <a href=\"/blog/hermes-profiles-to-docker\">my post on running Hermes profiles in Docker containers</a>. In that post I described mounting each profile to its own container, setting up systemd to start them on boot, and declaring victory.</p>\n<p>I was wrong.</p>\n<p>Not wrong about the architecture. Wrong about whether it was actually working.</p>\n<p><strong>TL;DR:</strong> I declared the Docker setup working. Then cron jobs vanished into the wrong database, sage was running two Hermes instances, and the actual bug turned out to be a Python loop that had nothing to do with Docker.</p>\n<h2>The Problem I Didn't Know I Had (Again)</h2>\n<p>I ran <code>docker exec hermes-sage ps aux</code> to verify sage was healthy. It showed one process. Good. I fired a test cron on sage. It said it created successfully. I fired a test cron on rex. It said it created successfully. I waited.</p>\n<p>Nothing arrived.</p>\n<p>I assumed the Telegram bots weren't set up correctly. I went down a whole path of checking bot tokens, allowed user lists, <code>TELEGRAM_HOME_CHANNEL</code> settings. Then I checked the logs and found something stranger.</p>\n<p>Sage's cron engine wasn't running the jobs. Neither was rex's. They were being created (the CLI returned success), but they were going into <em>the host's</em> state database, not the container's.</p>\n<p>And underneath that, I discovered something worse: sage wasn't running one Hermes instance. It was running two.</p>\n<h2>Why 'ps aux' Lied to Me</h2>\n<p>The Docker entrypoint for the Hermes image is <code>/init</code>, which is <a href=\"https://github.com/just-containers/s6-overlay\">s6-overlay</a>. s6-overlay scans <code>/run/service/</code> and starts everything it finds there. My compose file passed <code>--profile sage</code> to the gateway command, which <em>should</em> have meant \"run sage profile only.\"</p>\n<p>What actually happened: the compose <code>command:</code> was ignored. The ENTRYPOINT (<code>/init</code>) runs as PID 1, starts s6, and s6 starts <em>all</em> the services in <code>/run/service/</code>, <code>gateway-default</code> and <code>gateway-sage</code> both. Two Hermes instances, same process tree.</p>\n<p>The <code>ps aux</code> I'd run to \"verify\" sage was healthy showed one gateway at the top of the output, so I stopped reading. The output ran longer than one screen. When I finally ran <code>docker exec hermes-sage ps aux | grep gateway</code>, two gateway processes showed up: the one I expected, and a second one nested under s6 that I'd scrolled straight past.</p>\n<p>The compose <code>command:</code> instruction doesn't override an ENTRYPOINT. It gets handed to the entrypoint as arguments instead. That's in Docker's docs under how ENTRYPOINT and CMD interact. I'd never internalised it.</p>\n<h2>The Cron Database Shell Game</h2>\n<p>Once I grasped that sage was running two instances, the cron mystery made more sense. The CLI command <code>hermes cron create</code> was running inside the container, but it reads its config from environment variables, <code>HOME</code> especially, since that's where Hermes looks for its config directory.</p>\n<p>I checked what HOME actually was inside the container:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">$ docker exec hermes-sage env </span><span style=\"color:#F97583\">|</span><span style=\"color:#B392F0\"> grep</span><span style=\"color:#9ECBFF\"> HOME</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">HOME=/home/luke</span></span></code></pre>\n<p>That's the hermes user's passwd entry, not <code>/opt/data</code>. So when <code>hermes cron create</code> ran without <code>--profile sage</code>, it looked for config at <code>/home/luke/.hermes</code>, which doesn't exist in the container, and fell back to writing jobs to <code>/opt/data/cron/jobs.json</code> instead of <code>/opt/data/profiles/sage/cron/jobs.json</code>.</p>\n<p>The sage gateway was reading from the right place. The cron CLI was writing to the wrong place. Jobs created successfully. Jobs never fired.</p>\n<p>The fix was passing <code>--profile sage</code> to every cron command inside the container. But I also had to fix HOME, which led to the next problem.</p>\n<h2>The su -m Trap</h2>\n<p>I wanted the hermes process to run with <code>HOME=/opt/data</code> so it read from the right config. The entrypoint script ran as root, then used <code>su hermes</code> to drop privileges. Easy enough.</p>\n<p><code>su -m</code> is meant to preserve the parent environment, so I set <code>HOME=/opt/data</code> before calling <code>su</code> and expected it to carry through. It didn't. The shell <code>su</code> spawned was resetting HOME back to the hermes user's passwd entry (<code>/home/luke</code>) on the way up, undoing what <code>su -m</code> had preserved. By the time hermes actually ran, HOME had been clobbered again.</p>\n<p>The reliable fix was <code>env -i</code>, which wipes the environment entirely before setting only what I want. Nothing left for the shell to reset:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF\">exec</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#79B8FF\"> -i</span><span style=\"color:#9ECBFF\"> HOME=/opt/data</span><span style=\"color:#9ECBFF\"> HERMES_HOME=/opt/data</span><span style=\"color:#9ECBFF\"> HERMES_PROFILE=sage</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  su</span><span style=\"color:#79B8FF\"> -m</span><span style=\"color:#79B8FF\"> -s</span><span style=\"color:#9ECBFF\"> /bin/sh</span><span style=\"color:#9ECBFF\"> hermes</span><span style=\"color:#79B8FF\"> -c</span><span style=\"color:#9ECBFF\"> \"exec hermes gateway run --profile sage\"</span></span></code></pre>\n<p><code>env -i</code> clears everything, the explicit vars set what I need, and <code>su -m</code> preserves that clean state. hermes starts with exactly the HOME I specify.</p>\n<h2>Custom Entrypoint: Replacing PID 1</h2>\n<p>To stop <code>gateway-default</code> from starting, I needed to keep s6 from scanning it. The cleanest approach was replacing PID 1 entirely with a custom script that:</p>\n<ol>\n<li>Runs <code>stage2-hook.sh</code> for Hermes bootstrap (UID remapping, chown)</li>\n<li>Waits for s6 to register its services</li>\n<li>Kills <code>gateway-default</code> via <code>s6-svc -d</code></li>\n<li>Starts only the sage gateway, using that same <code>env -i</code> exec line from above</li>\n</ol>\n<p>Then the compose file binds the script as the entrypoint:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">entrypoint</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"/entrypoint.sh\"</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  - </span><span style=\"color:#9ECBFF\">/home/luke/.hermes/docker/entrypoints/sage-only.sh:/entrypoint.sh:ro</span></span></code></pre>\n<p>Now sage starts exactly one Hermes instance. No gateway-default. No confusion.</p>\n<h2>The Telegram 'Chat Not Found' Problem</h2>\n<p>Once I had real isolation working, the crons fired. The sage cron engine ran its job. The rex cron engine ran its job. Both logged <code>completed successfully</code>.</p>\n<p>No Telegram messages arrived.</p>\n<p>The error: <code>Telegram send failed: Chat not found</code>.</p>\n<p>Sage has its own Telegram bot. Rex has its own Telegram bot. Separate bots, separate tokens. A Telegram bot can only send messages to people who have messaged that specific bot first. I'd only ever messaged the default Hermes bot, which knew about my chat. Sage's bot had never seen me.</p>\n<p>The second issue was <code>TELEGRAM_HOME_CHANNEL=RealLukeManning</code> in the <code>.env</code>. That's a username, not a chat ID. The Telegram Bot API accepts numeric <code>chat_id</code> for any chat, but only public channels and supergroups can be addressed via <code>@username</code> — for private chats (the home chat here), the API silently fails on a username. Sage was using the username and failing silently.</p>\n<p>The fix: message each bot directly first, or use numeric chat IDs in the delivery target (<code>telegram:491962736</code>, not <code>telegram:RealLukeManning</code>).</p>\n<h2>The env_file Trap</h2>\n<p>I thought the Telegram issue was just setup. Then I noticed sage's logs showed its Telegram module attempting to connect, but the bot token wasn't in the container's config.</p>\n<p>The <code>.env</code> with the bot token was on the host at <code>~/.hermes/profiles/sage/.env</code>. The container mounts the profile directory to <code>/opt/data/profiles/sage</code>. The hermes process runs from <code>/opt/data</code>. No <code>.env</code> at <code>/opt/data</code>.</p>\n<p>The compose file wasn't passing it through:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Missing</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">env_file</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  - </span><span style=\"color:#9ECBFF\">/home/luke/.hermes/profiles/sage/.env</span></span></code></pre>\n<p>Without the token, the Telegram connection failed gracefully, logged a warning, and didn't die. Messages never arrived.</p>\n<h2>The Final Test (Or So I Thought)</h2>\n<p>With all fixes in place, I fired simultaneous test crons on both containers. Both ran. Both Telegram bots delivered. Five seconds apart, completely independent.</p>\n<p>Sage: one instance, sage profile, sage bot, sage cron. Rex: one instance, rex profile, rex bot, rex cron. Each container starting independently via systemd, each with its <code>.env</code> passed through, cron jobs created with <code>--profile</code> and numeric chat IDs.</p>\n<p>I thought that was it. The containers were finally doing what I'd built them to do.</p>\n<p>Then last Tuesday I got a message from Sage at 8am.</p>\n<p>The daily system health check I'd set up months ago, before Docker and before any of this, was delivering through Sage's Telegram bot instead of the default gateway.</p>\n<p>I swore up and down I'd fixed this. I had not fixed this.</p>\n<h2>What the Health Check Was Actually Doing</h2>\n<p>The health check runs every morning at 8am. Disk, swap, fail2ban, SSH failures. Then it delivers the result to Telegram. Crucially, it runs on the bare-metal default gateway, the one I never put in a container. Not sage, not rex.</p>\n<p>The job itself was fine. The delivery was the problem. I opened the health-check cron job to see what it actually called, and found it was running a skill called <code>hermes-telegram-send</code>, one I didn't write, generated by an LLM using a template. That skill had one job: send a Telegram message from a cron context.</p>\n<p>The skill worked by loading environment variables from profile <code>.env</code> files, in this order:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> profile </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#9ECBFF\">\"rex\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">\"sage\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">\"default\"</span><span style=\"color:#E1E4E8\">]:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    env_file </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> Path(</span><span style=\"color:#F97583\">f</span><span style=\"color:#9ECBFF\">\"/home/luke/.hermes/profiles/</span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">profile</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">/.env\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> env_file.exists():</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        for</span><span style=\"color:#E1E4E8\"> line </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> env_file.read_text().splitlines():</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # load the token</span></span></code></pre>\n<p><code>rex/.env</code> exists, loads token. <code>sage/.env</code> exists, overwrites the token. <code>default/.env</code> doesn't exist, skipped.</p>\n<p>Sage's Telegram bot token was the last one loaded. So when the health check cron ran, it used Sage's bot to send the message.</p>\n<p>The message came from Sage. Because the skill hardcoded <code>[\"rex\", \"sage\", \"default\"]</code> and <code>default</code> didn't exist.</p>\n<h2>The Docker Containers Were Working Fine</h2>\n<p>This is the part that felt stupid to realise.</p>\n<p>Sage was never misconfigured. She wasn't \"replying when she shouldn't.\" She was doing exactly what her container was set up to do. The Docker isolation I'd spent a whole post documenting and debugging was completely correct. Separate containers, separate bots, separate processes. All working.</p>\n<p>The bug wasn't in Docker. It wasn't in the architecture. It was in a Python loop in a skill that was generated without thinking about what happens when one of the hardcoded profile names doesn't exist.</p>\n<p>The loop was supposed to try multiple profiles in case one didn't have a token. It would load <code>rex</code>, then <code>sage</code>, then <code>default</code>. The last one loaded would be the one used.</p>\n<p>But there's no <code>default</code> profile directory. There's a bare-metal Hermes gateway running directly on the host, with its own <code>.env</code> at <code>~/.hermes/.env</code>. That file has the actual Telegram token for the main bot.</p>\n<p>The skill didn't know about <code>~/.hermes/.env</code>. It only knew about the profile subdirectories.</p>\n<h2>The Real Fix: Baseline First, Never Overwrite</h2>\n<p>The skill now loads <code>~/.hermes/.env</code> <strong>first</strong> as a baseline (the bare-metal gateway's token), then profile <code>.env</code> files only fill in empty slots. They never overwrite what's already set.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ~/.hermes/.env loads FIRST as baseline</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">main_env </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> Path(</span><span style=\"color:#F97583\">f</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">hermes_home</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">/.env\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> main_env.exists():</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    for</span><span style=\"color:#E1E4E8\"> line </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> main_env.read_text().splitlines():</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#F97583\"> not</span><span style=\"color:#E1E4E8\"> line.strip() </span><span style=\"color:#F97583\">or</span><span style=\"color:#E1E4E8\"> line.lstrip().startswith(</span><span style=\"color:#9ECBFF\">\"#\"</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">or</span><span style=\"color:#9ECBFF\"> \"=\"</span><span style=\"color:#F97583\"> not</span><span style=\"color:#F97583\"> in</span><span style=\"color:#E1E4E8\"> line:</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            continue</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        k, v </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> line.split(</span><span style=\"color:#9ECBFF\">\"=\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        os.environ[k] </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> v  </span><span style=\"color:#6A737D\"># no guard, this is the baseline</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Profile files only fill empty slots, never overwrite</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">profile_order </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> ([current_profile] </span><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> current_profile </span><span style=\"color:#F97583\">else</span><span style=\"color:#E1E4E8\"> []) </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#9ECBFF\">\"rex\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">\"sage\"</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> profile </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> profile_order:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    env_file </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> Path(</span><span style=\"color:#F97583\">f</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">hermes_home</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">/profiles/</span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">profile</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">/.env\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> env_file.exists():</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        for</span><span style=\"color:#E1E4E8\"> line </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> env_file.read_text().splitlines():</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            if</span><span style=\"color:#F97583\"> not</span><span style=\"color:#E1E4E8\"> line.strip() </span><span style=\"color:#F97583\">or</span><span style=\"color:#E1E4E8\"> line.lstrip().startswith(</span><span style=\"color:#9ECBFF\">\"#\"</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">or</span><span style=\"color:#9ECBFF\"> \"=\"</span><span style=\"color:#F97583\"> not</span><span style=\"color:#F97583\"> in</span><span style=\"color:#E1E4E8\"> line:</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">                continue</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            k, v </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> line.split(</span><span style=\"color:#9ECBFF\">\"=\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            if</span><span style=\"color:#E1E4E8\"> k </span><span style=\"color:#F97583\">not</span><span style=\"color:#F97583\"> in</span><span style=\"color:#E1E4E8\"> os.environ:  </span><span style=\"color:#6A737D\"># only fill empty slots</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                os.environ[k] </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> v</span></span></code></pre>\n<p>The old code had two problems. First, when <code>HERMES_PROFILE</code> is empty (bare-metal default), the current profile list is empty and <code>[\"rex\", \"sage\"]</code> loads in that order, so sage overwrites rex. Second, the profile loop had <strong>no guard</strong>. <code>os.environ[k] = v</code> always overwrites, so even if the main <code>.env</code> loaded last, it was already too late. The fix: main <code>.env</code> first (no guard), profile files second (with <code>if k not in os.environ</code> guard).</p>\n<p>The health check at 8am tomorrow should come from the right bot. For real this time.</p>\n<h2>What I Actually Learned</h2>\n<p>The implementation had three layers of subtle breakage. The compose <code>command:</code> doesn't override an ENTRYPOINT; if the image has one, the command gets passed to it as arguments. HOME matters more than I expected when <code>su</code>-ing to another user; <code>env -i</code> is the reliable way to lock it. And separate containers don't mean isolated if the entrypoint starts everything inside them regardless.</p>\n<p>I fixed all of that. It was real work. And then the bug that actually mattered turned out to be none of it.</p>\n<p>Three things I keep having to re-learn:</p>\n<p><strong>Infrastructure work doesn't protect you from code bugs.</strong> I spent weeks on Docker containers, systemd services, separate Telegram bots. All correct, all irrelevant to the actual symptom. A hardcoded list of profile names in a Python loop. The containers couldn't catch that, because containers don't catch Python logic errors.</p>\n<p><strong>LLM-generated skills inherit LLM failure modes.</strong> The skill came from a template with <code>[\"rex\", \"sage\", \"default\"]</code> baked in. Nobody stopped to ask what happens when <code>default</code> doesn't exist. That kind of assumption sits in code for months before anyone notices.</p>\n<p><strong>A fix I didn't verify was never really a fix.</strong> I thought I'd solved the Sage problem once isolation was working. The real fix had two subtle bugs hiding in it: an empty <code>HERMES_PROFILE</code> that silently disabled the \"current profile first\" logic, and a profile loop that overwrote tokens without checking if one was already set. I should have verified instead of trusting it.</p>\n<p>The Docker setup is better now than before I started. The isolation is real. But the real fix required iterating on a Python loop that had nothing to do with Docker.</p>",
            "url": "https://lukemanning.ie/blog/hermes-profiles-to-docker-part-two",
            "title": "My Docker Containers Were Working. The Bug Was in a Python Loop.",
            "summary": "<p>This is a follow-up to <a href=\"/blog/hermes-profiles-to-docker\">my post on running Hermes profiles in Docker containers</a>. In that post I described mounting each profile to its own container, setting up systemd to start them on boot, and declaring victory.</p>\n<p>I was wrong.</p>\n<p>Not wrong about the architecture. Wrong about whether it was actually working.</p>\n<p><strong>TL;DR:</strong> I declared the Docker setup working. Then cron jobs vanished into the wrong database, sage was running two Hermes instances, and the actual bug turned out to be a Python loop that had nothing to do with Docker.</p>\n<h2>The Problem I Didn't Know I Had (Again)</h2>\n<p>I ran <code>docker exec hermes-sage ps aux</code> to verify sage was healthy. It showed one process. Good. I fired a test cron on sage. It said it created successfully. I fired a test cron on rex. It said it created successfully. I waited.</p>\n<p>Nothing arrived.</p>\n<p>I assumed the Telegram bots weren't set up correctly. I went down a whole path of checking bot tokens, allowed user lists, <code>TELEGRAM_HOME_CHANNEL</code> settings. Then I checked the logs and found something stranger.</p>\n<p>Sage's cron engine wasn't running the jobs. Neither was rex's. They were being created (the CLI returned success), but they were going into <em>the host's</em> state database, not the container's.</p>\n<p>And underneath that, I discovered something worse: sage wasn't running one Hermes instance. It was running two.</p>\n<h2>Why 'ps aux' Lied to Me</h2>\n<p>The Docker entrypoint for the Hermes image is <code>/init</code>, which is <a href=\"https://github.com/just-containers/s6-overlay\">s6-overlay</a>. s6-overlay scans <code>/run/service/</code> and starts everything it finds there. My compose file passed <code>--profile sage</code> to the gateway command, which <em>should</em> have meant \"run sage profile only.\"</p>\n<p>What actually happened: the compose <code>command:</code> was ignored. The ENTRYPOINT (<code>/init</code>) runs as PID 1, starts s6, and s6 starts <em>all</em> the services in <code>/run/service/</code>, <code>gateway-default</code> and <code>gateway-sage</code> both. Two Hermes instances, same process tree.</p>\n<p>The <code>ps aux</code> I'd run to \"verify\" sage was healthy showed one gateway at the top of the output, so I stopped reading. The output ran longer than one screen. When I finally ran <code>docker exec hermes-sage ps aux | grep gateway</code>, two gateway processes showed up: the one I expected, and a second one nested under s6 that I'd scrolled straight past.</p>\n<p>The compose <code>command:</code> instruction doesn't override an ENTRYPOINT. It gets handed to the entrypoint as arguments instead. That's in Docker's docs under how ENTRYPOINT and CMD interact. I'd never internalised it.</p>\n<h2>The Cron Database Shell Game</h2>\n<p>Once I grasped that sage was running two instances, the cron mystery made more sense. The CLI command <code>hermes cron create</code> was running inside the container, but it reads its config from environment variables, <code>HOME</code> especially, since that's where Hermes looks for its config directory.</p>\n<p>I checked what HOME actually was inside the container:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">$ docker exec hermes-sage env </span><span style=\"color:#F97583\">|</span><span style=\"color:#B392F0\"> grep</span><span style=\"color:#9ECBFF\"> HOME</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">HOME=/home/luke</span></span></code></pre>\n<p>That's the hermes user's passwd entry, not <code>/opt/data</code>. So when <code>hermes cron create</code> ran without <code>--profile sage</code>, it looked for config at <code>/home/luke/.hermes</code>, which doesn't exist in the container, and fell back to writing jobs to <code>/opt/data/cron/jobs.json</code> instead of <code>/opt/data/profiles/sage/cron/jobs.json</code>.</p>\n<p>The sage gateway was reading from the right place. The cron CLI was writing to the wrong place. Jobs created successfully. Jobs never fired.</p>\n<p>The fix was passing <code>--profile sage</code> to every cron command inside the container. But I also had to fix HOME, which led to the next problem.</p>\n<h2>The su -m Trap</h2>\n<p>I wanted the hermes process to run with <code>HOME=/opt/data</code> so it read from the right config. The entrypoint script ran as root, then used <code>su hermes</code> to drop privileges. Easy enough.</p>\n<p><code>su -m</code> is meant to preserve the parent environment, so I set <code>HOME=/opt/data</code> before calling <code>su</code> and expected it to carry through. It didn't. The shell <code>su</code> spawned was resetting HOME back to the hermes user's passwd entry (<code>/home/luke</code>) on the way up, undoing what <code>su -m</code> had preserved. By the time hermes actually ran, HOME had been clobbered again.</p>\n<p>The reliable fix was <code>env -i</code>, which wipes the environment entirely before setting only what I want. Nothing left for the shell to reset:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF\">exec</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#79B8FF\"> -i</span><span style=\"color:#9ECBFF\"> HOME=/opt/data</span><span style=\"color:#9ECBFF\"> HERMES_HOME=/opt/data</span><span style=\"color:#9ECBFF\"> HERMES_PROFILE=sage</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  su</span><span style=\"color:#79B8FF\"> -m</span><span style=\"color:#79B8FF\"> -s</span><span style=\"color:#9ECBFF\"> /bin/sh</span><span style=\"color:#9ECBFF\"> hermes</span><span style=\"color:#79B8FF\"> -c</span><span style=\"color:#9ECBFF\"> \"exec hermes gateway run --profile sage\"</span></span></code></pre>\n<p><code>env -i</code> clears everything, the explicit vars set what I need, and <code>su -m</code> preserves that clean state. hermes starts with exactly the HOME I specify.</p>\n<h2>Custom Entrypoint: Replacing PID 1</h2>\n<p>To stop <code>gateway-default</code> from starting, I needed to keep s6 from scanning it. The cleanest approach was replacing PID 1 entirely with a custom script that:</p>\n<ol>\n<li>Runs <code>stage2-hook.sh</code> for Hermes bootstrap (UID remapping, chown)</li>\n<li>Waits for s6 to register its services</li>\n<li>Kills <code>gateway-default</code> via <code>s6-svc -d</code></li>\n<li>Starts only the sage gateway, using that same <code>env -i</code> exec line from above</li>\n</ol>\n<p>Then the compose file binds the script as the entrypoint:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">entrypoint</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"/entrypoint.sh\"</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  - </span><span style=\"color:#9ECBFF\">/home/luke/.hermes/docker/entrypoints/sage-only.sh:/entrypoint.sh:ro</span></span></code></pre>\n<p>Now sage starts exactly one Hermes instance. No gateway-default. No confusion.</p>\n<h2>The Telegram 'Chat Not Found' Problem</h2>\n<p>Once I had real isolation working, the crons fired. The sage cron engine ran its job. The rex cron engine ran its job. Both logged <code>completed successfully</code>.</p>\n<p>No Telegram messages arrived.</p>\n<p>The error: <code>Telegram send failed: Chat not found</code>.</p>\n<p>Sage has its own Telegram bot. Rex has its own Telegram bot. Separate bots, separate tokens. A Telegram bot can only send messages to people who have messaged that specific bot first. I'd only ever messaged the default Hermes bot, which knew about my chat. Sage's bot had never seen me.</p>\n<p>The second issue was <code>TELEGRAM_HOME_CHANNEL=RealLukeManning</code> in the <code>.env</code>. That's a username, not a chat ID. The Telegram Bot API accepts numeric <code>chat_id</code> for any chat, but only public channels and supergroups can be addressed via <code>@username</code> — for private chats (the home chat here), the API silently fails on a username. Sage was using the username and failing silently.</p>\n<p>The fix: message each bot directly first, or use numeric chat IDs in the delivery target (<code>telegram:491962736</code>, not <code>telegram:RealLukeManning</code>).</p>\n<h2>The env_file Trap</h2>\n<p>I thought the Telegram issue was just setup. Then I noticed sage's logs showed its Telegram module attempting to connect, but the bot token wasn't in the container's config.</p>\n<p>The <code>.env</code> with the bot token was on the host at <code>~/.hermes/profiles/sage/.env</code>. The container mounts the profile directory to <code>/opt/data/profiles/sage</code>. The hermes process runs from <code>/opt/data</code>. No <code>.env</code> at <code>/opt/data</code>.</p>\n<p>The compose file wasn't passing it through:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Missing</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">env_file</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  - </span><span style=\"color:#9ECBFF\">/home/luke/.hermes/profiles/sage/.env</span></span></code></pre>\n<p>Without the token, the Telegram connection failed gracefully, logged a warning, and didn't die. Messages never arrived.</p>\n<h2>The Final Test (Or So I Thought)</h2>\n<p>With all fixes in place, I fired simultaneous test crons on both containers. Both ran. Both Telegram bots delivered. Five seconds apart, completely independent.</p>\n<p>Sage: one instance, sage profile, sage bot, sage cron. Rex: one instance, rex profile, rex bot, rex cron. Each container starting independently via systemd, each with its <code>.env</code> passed through, cron jobs created with <code>--profile</code> and numeric chat IDs.</p>\n<p>I thought that was it. The containers were finally doing what I'd built them to do.</p>\n<p>Then last Tuesday I got a message from Sage at 8am.</p>\n<p>The daily system health check I'd set up months ago, before Docker and before any of this, was delivering through Sage's Telegram bot instead of the default gateway.</p>\n<p>I swore up and down I'd fixed this. I had not fixed this.</p>\n<h2>What the Health Check Was Actually Doing</h2>\n<p>The health check runs every morning at 8am. Disk, swap, fail2ban, SSH failures. Then it delivers the result to Telegram. Crucially, it runs on the bare-metal default gateway, the one I never put in a container. Not sage, not rex.</p>\n<p>The job itself was fine. The delivery was the problem. I opened the health-check cron job to see what it actually called, and found it was running a skill called <code>hermes-telegram-send</code>, one I didn't write, generated by an LLM using a template. That skill had one job: send a Telegram message from a cron context.</p>\n<p>The skill worked by loading environment variables from profile <code>.env</code> files, in this order:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> profile </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#9ECBFF\">\"rex\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">\"sage\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">\"default\"</span><span style=\"color:#E1E4E8\">]:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    env_file </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> Path(</span><span style=\"color:#F97583\">f</span><span style=\"color:#9ECBFF\">\"/home/luke/.hermes/profiles/</span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">profile</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">/.env\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> env_file.exists():</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        for</span><span style=\"color:#E1E4E8\"> line </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> env_file.read_text().splitlines():</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # load the token</span></span></code></pre>\n<p><code>rex/.env</code> exists, loads token. <code>sage/.env</code> exists, overwrites the token. <code>default/.env</code> doesn't exist, skipped.</p>\n<p>Sage's Telegram bot token was the last one loaded. So when the health check cron ran, it used Sage's bot to send the message.</p>\n<p>The message came from Sage. Because the skill hardcoded <code>[\"rex\", \"sage\", \"default\"]</code> and <code>default</code> didn't exist.</p>\n<h2>The Docker Containers Were Working Fine</h2>\n<p>This is the part that felt stupid to realise.</p>\n<p>Sage was never misconfigured. She wasn't \"replying when she shouldn't.\" She was doing exactly what her container was set up to do. The Docker isolation I'd spent a whole post documenting and debugging was completely correct. Separate containers, separate bots, separate processes. All working.</p>\n<p>The bug wasn't in Docker. It wasn't in the architecture. It was in a Python loop in a skill that was generated without thinking about what happens when one of the hardcoded profile names doesn't exist.</p>\n<p>The loop was supposed to try multiple profiles in case one didn't have a token. It would load <code>rex</code>, then <code>sage</code>, then <code>default</code>. The last one loaded would be the one used.</p>\n<p>But there's no <code>default</code> profile directory. There's a bare-metal Hermes gateway running directly on the host, with its own <code>.env</code> at <code>~/.hermes/.env</code>. That file has the actual Telegram token for the main bot.</p>\n<p>The skill didn't know about <code>~/.hermes/.env</code>. It only knew about the profile subdirectories.</p>\n<h2>The Real Fix: Baseline First, Never Overwrite</h2>\n<p>The skill now loads <code>~/.hermes/.env</code> <strong>first</strong> as a baseline (the bare-metal gateway's token), then profile <code>.env</code> files only fill in empty slots. They never overwrite what's already set.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ~/.hermes/.env loads FIRST as baseline</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">main_env </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> Path(</span><span style=\"color:#F97583\">f</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">hermes_home</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">/.env\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> main_env.exists():</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    for</span><span style=\"color:#E1E4E8\"> line </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> main_env.read_text().splitlines():</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#F97583\"> not</span><span style=\"color:#E1E4E8\"> line.strip() </span><span style=\"color:#F97583\">or</span><span style=\"color:#E1E4E8\"> line.lstrip().startswith(</span><span style=\"color:#9ECBFF\">\"#\"</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">or</span><span style=\"color:#9ECBFF\"> \"=\"</span><span style=\"color:#F97583\"> not</span><span style=\"color:#F97583\"> in</span><span style=\"color:#E1E4E8\"> line:</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            continue</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        k, v </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> line.split(</span><span style=\"color:#9ECBFF\">\"=\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        os.environ[k] </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> v  </span><span style=\"color:#6A737D\"># no guard, this is the baseline</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Profile files only fill empty slots, never overwrite</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">profile_order </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> ([current_profile] </span><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> current_profile </span><span style=\"color:#F97583\">else</span><span style=\"color:#E1E4E8\"> []) </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#9ECBFF\">\"rex\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">\"sage\"</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> profile </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> profile_order:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    env_file </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> Path(</span><span style=\"color:#F97583\">f</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">hermes_home</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">/profiles/</span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">profile</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">/.env\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> env_file.exists():</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        for</span><span style=\"color:#E1E4E8\"> line </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> env_file.read_text().splitlines():</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            if</span><span style=\"color:#F97583\"> not</span><span style=\"color:#E1E4E8\"> line.strip() </span><span style=\"color:#F97583\">or</span><span style=\"color:#E1E4E8\"> line.lstrip().startswith(</span><span style=\"color:#9ECBFF\">\"#\"</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">or</span><span style=\"color:#9ECBFF\"> \"=\"</span><span style=\"color:#F97583\"> not</span><span style=\"color:#F97583\"> in</span><span style=\"color:#E1E4E8\"> line:</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">                continue</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            k, v </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> line.split(</span><span style=\"color:#9ECBFF\">\"=\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            if</span><span style=\"color:#E1E4E8\"> k </span><span style=\"color:#F97583\">not</span><span style=\"color:#F97583\"> in</span><span style=\"color:#E1E4E8\"> os.environ:  </span><span style=\"color:#6A737D\"># only fill empty slots</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                os.environ[k] </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> v</span></span></code></pre>\n<p>The old code had two problems. First, when <code>HERMES_PROFILE</code> is empty (bare-metal default), the current profile list is empty and <code>[\"rex\", \"sage\"]</code> loads in that order, so sage overwrites rex. Second, the profile loop had <strong>no guard</strong>. <code>os.environ[k] = v</code> always overwrites, so even if the main <code>.env</code> loaded last, it was already too late. The fix: main <code>.env</code> first (no guard), profile files second (with <code>if k not in os.environ</code> guard).</p>\n<p>The health check at 8am tomorrow should come from the right bot. For real this time.</p>\n<h2>What I Actually Learned</h2>\n<p>The implementation had three layers of subtle breakage. The compose <code>command:</code> doesn't override an ENTRYPOINT; if the image has one, the command gets passed to it as arguments. HOME matters more than I expected when <code>su</code>-ing to another user; <code>env -i</code> is the reliable way to lock it. And separate containers don't mean isolated if the entrypoint starts everything inside them regardless.</p>\n<p>I fixed all of that. It was real work. And then the bug that actually mattered turned out to be none of it.</p>\n<p>Three things I keep having to re-learn:</p>\n<p><strong>Infrastructure work doesn't protect you from code bugs.</strong> I spent weeks on Docker containers, systemd services, separate Telegram bots. All correct, all irrelevant to the actual symptom. A hardcoded list of profile names in a Python loop. The containers couldn't catch that, because containers don't catch Python logic errors.</p>\n<p><strong>LLM-generated skills inherit LLM failure modes.</strong> The skill came from a template with <code>[\"rex\", \"sage\", \"default\"]</code> baked in. Nobody stopped to ask what happens when <code>default</code> doesn't exist. That kind of assumption sits in code for months before anyone notices.</p>\n<p><strong>A fix I didn't verify was never really a fix.</strong> I thought I'd solved the Sage problem once isolation was working. The real fix had two subtle bugs hiding in it: an empty <code>HERMES_PROFILE</code> that silently disabled the \"current profile first\" logic, and a profile loop that overwrote tokens without checking if one was already set. I should have verified instead of trusting it.</p>\n<p>The Docker setup is better now than before I started. The isolation is real. But the real fix required iterating on a Python loop that had nothing to do with Docker.</p>",
            "date_modified": "2026-07-20T00:00:00.000Z",
            "tags": [
                "hermes",
                "homelab",
                "docker"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/hermes-profiles-to-docker",
            "content_html": "<p>I ran Hermes (the self-hosted agent I use for <a href=\"/blog/setting-up-camoufox-with-hermes\">reading and research</a> and a few other jobs) with three profiles: default, sage, and rex. Each was a separate persona with its own skills and its own Telegram bot.</p>\n<p>They were gateway profiles. In Hermes, the gateway is the long-running process that polls Telegram, runs scheduled jobs, and executes skills. \"Profiles\" means one gateway binary, one process manager, just different configs passed in via <code>--profile</code>.</p>\n<p>One thing worth saying up front. This post is the \"why I tried containers\" half of the story. When I went to verify the setup, it wasn't actually working yet, and that's covered in <a href=\"/blog/hermes-profiles-to-docker-part-two\">part two of the series</a>. I'm keeping this one focused on the reasoning and the container plumbing, because that part still stands even though my diagnosis turned out to be off.</p>\n<h2>The Problem I Didn't Know I Had</h2>\n<p>The symptom was cron replies coming from the wrong profile, and it was inconsistent, which is what made it hard to pin down. Sometimes a scheduled job would fire and default and sage would both reply. Sometimes only rex would send the update, when it wasn't his job to. It never happened with messages I sent directly — only cron.</p>\n<p>My theory at the time was that the profiles shared too much. One scheduler, one process tree, no hard boundary between them, so jobs bled across. Three profiles all live in the same process, so when a cron job fired, I figured whichever gateway instance was free picked it up.</p>\n<p>That theory turned out to be mostly wrong, which is a whole <a href=\"/blog/hermes-profiles-to-docker-part-two\">separate story</a>. I'm laying it out anyway, because it's what drove me to containers, and the architecture reasoning is sound even if the diagnosis wasn't.</p>\n<p>What I didn't realise at the time: gateway profiles aren't separate services. They're separate config directories, separate skill directories, separate <code>.env</code> files, and a <code>--profile</code> flag handed to the same gateway binary. Same process tree. Same supervisor. No hard boundary when one of them misbehaves.</p>\n<p>I'd been treating them like independent services. They're not.</p>\n<h2>What the Docs Recommended (and Why I Went Further)</h2>\n<p>The Hermes docs recommend one container hosting all profiles, with <a href=\"https://github.com/just-containers/s6-overlay\">s6-overlay</a> (a process supervisor designed for containers) managing each profile as a first-class service. Mount the whole <code>~/.hermes</code> directory to <code>/opt/data</code>, create profiles with <code>hermes profile create</code>, let s6 start and stop them.</p>\n<p>I didn't think that would solve what I was seeing. With one container and s6 running every profile, sage and rex are still co-located processes sharing a network namespace and a data directory. If the problem was jobs bleeding across profiles, bundling them into one container wouldn't stop any of it. They'd just be supervised versions of the same shared setup.</p>\n<p>So I ran separate containers for sage and rex, each mounting only its own profile directory. Real process isolation: separate network namespaces, separate PID 1, separate Telegram polling loops. At the process level they genuinely cannot interfere with each other.</p>\n<p>The architecture was right, it just wasn't the cause of what I was seeing. That part's in <a href=\"/blog/hermes-profiles-to-docker-part-two\">the verification post</a>.</p>\n<h2>The Mount Path Mistake</h2>\n<p>The mount paths. I spent far too long on this.</p>\n<p>The profile needs to live at <code>/opt/data/profiles/&#x3C;name></code> inside the container, not at <code>/opt/data</code>. I kept mounting the rex profile to <code>/opt/data</code>, and Hermes would log <code>Error: Profile 'rex' does not exist. Create it with: hermes profile create rex</code>. Same files on the host, same mount command, but Hermes couldn't find the profile because it was looking in the wrong place.</p>\n<p>The fix was obvious once I saw it:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Wrong — mounts to /opt/data, Hermes expects /opt/data/profiles/rex</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">- </span><span style=\"color:#9ECBFF\">/home/luke/.hermes/profiles/rex:/opt/data</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Right</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">- </span><span style=\"color:#9ECBFF\">/home/luke/.hermes/profiles/rex:/opt/data/profiles/rex</span></span></code></pre>\n<p>The containers weren't the wrong architecture. The paths were wrong. A simple mistake that cost more time than it should have.</p>\n<h2>The Boot Problem</h2>\n<p>Containers don't start themselves. Bare-metal services auto-start through systemd. I needed the Docker containers to come up on boot too.</p>\n<p><code>docker compose</code> has no native systemd integration. The cleanest approach was a systemd user service that runs <code>docker compose up -d</code> for each profile's compose file. It feels slightly off, systemd managing containers instead of services, but it's straightforward and it works.</p>\n<h2>What I'd Tell Myself</h2>\n<p>When I was running three gateway profiles on bare metal, I was already past what profiles are designed for. They're great for development. Spinning up a new persona with different skills takes seconds. For anything that needs to actually stay separated, the isolation story falls apart.</p>\n<p>Containers were the right architecture for that. The operational complexity is real (two compose files, a custom systemd service, separate log streams), but it's the right kind of complexity. Explicit, manageable, debuggable. When rex goes wrong, I look at the rex container, not a shared process tree.</p>\n<p>I still run default bare-metal. Default is the daily driver; it has no scheduled cron that fires critical replies. Sage and rex are the workers. They get containers.</p>\n<p>What I got wrong was assuming that building the right architecture meant the problem was solved. It didn't. When I went to verify the containers were actually doing their job, nothing worked. Sage was running two Hermes instances, cron jobs were vanishing into the wrong database, and the symptom that started all of this was still happening.</p>\n<p>That's <a href=\"/blog/hermes-profiles-to-docker-part-two\">the debugging follow-up</a>.</p>",
            "url": "https://lukemanning.ie/blog/hermes-profiles-to-docker",
            "title": "Why I Ditched Hermes Profiles for Docker Containers",
            "summary": "<p>I ran Hermes (the self-hosted agent I use for <a href=\"/blog/setting-up-camoufox-with-hermes\">reading and research</a> and a few other jobs) with three profiles: default, sage, and rex. Each was a separate persona with its own skills and its own Telegram bot.</p>\n<p>They were gateway profiles. In Hermes, the gateway is the long-running process that polls Telegram, runs scheduled jobs, and executes skills. \"Profiles\" means one gateway binary, one process manager, just different configs passed in via <code>--profile</code>.</p>\n<p>One thing worth saying up front. This post is the \"why I tried containers\" half of the story. When I went to verify the setup, it wasn't actually working yet, and that's covered in <a href=\"/blog/hermes-profiles-to-docker-part-two\">part two of the series</a>. I'm keeping this one focused on the reasoning and the container plumbing, because that part still stands even though my diagnosis turned out to be off.</p>\n<h2>The Problem I Didn't Know I Had</h2>\n<p>The symptom was cron replies coming from the wrong profile, and it was inconsistent, which is what made it hard to pin down. Sometimes a scheduled job would fire and default and sage would both reply. Sometimes only rex would send the update, when it wasn't his job to. It never happened with messages I sent directly — only cron.</p>\n<p>My theory at the time was that the profiles shared too much. One scheduler, one process tree, no hard boundary between them, so jobs bled across. Three profiles all live in the same process, so when a cron job fired, I figured whichever gateway instance was free picked it up.</p>\n<p>That theory turned out to be mostly wrong, which is a whole <a href=\"/blog/hermes-profiles-to-docker-part-two\">separate story</a>. I'm laying it out anyway, because it's what drove me to containers, and the architecture reasoning is sound even if the diagnosis wasn't.</p>\n<p>What I didn't realise at the time: gateway profiles aren't separate services. They're separate config directories, separate skill directories, separate <code>.env</code> files, and a <code>--profile</code> flag handed to the same gateway binary. Same process tree. Same supervisor. No hard boundary when one of them misbehaves.</p>\n<p>I'd been treating them like independent services. They're not.</p>\n<h2>What the Docs Recommended (and Why I Went Further)</h2>\n<p>The Hermes docs recommend one container hosting all profiles, with <a href=\"https://github.com/just-containers/s6-overlay\">s6-overlay</a> (a process supervisor designed for containers) managing each profile as a first-class service. Mount the whole <code>~/.hermes</code> directory to <code>/opt/data</code>, create profiles with <code>hermes profile create</code>, let s6 start and stop them.</p>\n<p>I didn't think that would solve what I was seeing. With one container and s6 running every profile, sage and rex are still co-located processes sharing a network namespace and a data directory. If the problem was jobs bleeding across profiles, bundling them into one container wouldn't stop any of it. They'd just be supervised versions of the same shared setup.</p>\n<p>So I ran separate containers for sage and rex, each mounting only its own profile directory. Real process isolation: separate network namespaces, separate PID 1, separate Telegram polling loops. At the process level they genuinely cannot interfere with each other.</p>\n<p>The architecture was right, it just wasn't the cause of what I was seeing. That part's in <a href=\"/blog/hermes-profiles-to-docker-part-two\">the verification post</a>.</p>\n<h2>The Mount Path Mistake</h2>\n<p>The mount paths. I spent far too long on this.</p>\n<p>The profile needs to live at <code>/opt/data/profiles/&#x3C;name></code> inside the container, not at <code>/opt/data</code>. I kept mounting the rex profile to <code>/opt/data</code>, and Hermes would log <code>Error: Profile 'rex' does not exist. Create it with: hermes profile create rex</code>. Same files on the host, same mount command, but Hermes couldn't find the profile because it was looking in the wrong place.</p>\n<p>The fix was obvious once I saw it:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Wrong — mounts to /opt/data, Hermes expects /opt/data/profiles/rex</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">- </span><span style=\"color:#9ECBFF\">/home/luke/.hermes/profiles/rex:/opt/data</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Right</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">- </span><span style=\"color:#9ECBFF\">/home/luke/.hermes/profiles/rex:/opt/data/profiles/rex</span></span></code></pre>\n<p>The containers weren't the wrong architecture. The paths were wrong. A simple mistake that cost more time than it should have.</p>\n<h2>The Boot Problem</h2>\n<p>Containers don't start themselves. Bare-metal services auto-start through systemd. I needed the Docker containers to come up on boot too.</p>\n<p><code>docker compose</code> has no native systemd integration. The cleanest approach was a systemd user service that runs <code>docker compose up -d</code> for each profile's compose file. It feels slightly off, systemd managing containers instead of services, but it's straightforward and it works.</p>\n<h2>What I'd Tell Myself</h2>\n<p>When I was running three gateway profiles on bare metal, I was already past what profiles are designed for. They're great for development. Spinning up a new persona with different skills takes seconds. For anything that needs to actually stay separated, the isolation story falls apart.</p>\n<p>Containers were the right architecture for that. The operational complexity is real (two compose files, a custom systemd service, separate log streams), but it's the right kind of complexity. Explicit, manageable, debuggable. When rex goes wrong, I look at the rex container, not a shared process tree.</p>\n<p>I still run default bare-metal. Default is the daily driver; it has no scheduled cron that fires critical replies. Sage and rex are the workers. They get containers.</p>\n<p>What I got wrong was assuming that building the right architecture meant the problem was solved. It didn't. When I went to verify the containers were actually doing their job, nothing worked. Sage was running two Hermes instances, cron jobs were vanishing into the wrong database, and the symptom that started all of this was still happening.</p>\n<p>That's <a href=\"/blog/hermes-profiles-to-docker-part-two\">the debugging follow-up</a>.</p>",
            "date_modified": "2026-07-19T00:00:00.000Z",
            "tags": [
                "hermes",
                "homelab",
                "docker"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/setting-up-camoufox-with-hermes",
            "content_html": "<p>I'd send Hermes links to read. Reddit threads, a couple of docs sites, the occasional opinion piece. Half of them wouldn't load. Bot detection. Just blocked.</p>\n<p>(Hermes is the local agent I use for <a href=\"/blog/building-a-reading-companion-for-my-vault\">reading and research</a>. Pointing it at a URL and expecting it to come back with something useful was the whole idea.)</p>\n<p>I didn't really know where to go with it until I stumbled on a thread in the Hermes subreddit about this exact problem. People recommended <a href=\"https://camoufox.com/\">Camoufox</a>, a Firefox fork with fingerprint spoofing built in, designed to look like a real browser to detection systems. I forwarded the thread to Hermes and said let's do this.</p>\n<h2>Setting Up Camoufox</h2>\n<p>Hermes took it from there. The thread pointed at a recommended architecture: a Node.js server called <code>camofox-browser</code> that wraps Camoufox and exposes a REST API, which Hermes has native support for. Hermes pulled it down, wired up the config, and started it.</p>\n<p>Here's what it configured. In <code>~/.hermes/.env</code>:</p>\n<pre><code>CAMOFOX_URL=http://localhost:9377\nCAMOFOX_ADOPT_EXISTING_TAB=true\n</code></pre>\n<p>And in <code>~/.hermes/config.yaml</code> under <code>browser.camofox</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">camofox</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  managed_persistence</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  adopt_existing_tab</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span></code></pre>\n<p>Health check came back clean. Looked done.</p>\n<h2>Tab Creation Kept Timing Out</h2>\n<p>It wasn't done. The first time Hermes tried to actually open a tab:</p>\n<pre><code>tab create timed out after 30000ms\nbrowserContext.newPage: Target page, context or browser has been closed\n</code></pre>\n<p>The browser launched. It just wouldn't open tabs. I assumed it was a resources thing at first. Wrong direction.</p>\n<p>So I asked Hermes to go digging. It traced it through the code — <code>firefox.launch(options)</code> was succeeding, but the IPC pipe (the juggler interface Playwright uses to talk to Firefox) was being blocked. Hermes grepped the camoufox package for sandbox-related flags and found nothing in the application code, which meant the problem was at the Firefox runtime level.</p>\n<p>It was. Firefox's content sandbox, a security feature that isolates content processes, was sealing off that IPC pipe. The browser launches fine, but the moment it tries to sandbox its child content processes, the pipe gets closed and the tab request dies before it ever gets a page.</p>\n<p>Two environment variables fixed it:</p>\n<pre><code>MOZ_DISABLE_CONTENT_SANDBOX=1\nMOZ_ENABLE_WAYLAND=0\n</code></pre>\n<p><code>MOZ_DISABLE_CONTENT_SANDBOX=1</code> turns the content sandbox off, unblocking the pipe. <code>MOZ_ENABLE_WAYLAND=0</code> forces Firefox onto X11 instead of Wayland — the sandbox behaviour was worse on Wayland, or the IPC just works more reliably over X11 for headless automation. Hermes set both in the same patch and restarted.</p>\n<p>The health check came back with <code>browserConnected: true</code> and <code>browserRunning: true</code>. Tab creation worked. Felt anticlimactic after all that back and forth.</p>\n<h2>Making It Stick</h2>\n<p>The persistence instinct was familiar — I'd already <a href=\"/blog/hermes-profiles-to-docker\">containerised my Hermes profiles</a> for basically the same reason. Hermes wrapped the camofox server in a user-level systemd service (<code>~/.config/systemd/user/camofox.service</code>) so it survives reboots, carrying both environment variables.</p>\n<p>The <code>[Service]</code> block ends up looking like:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">[Service]</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">Environment</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#F97583\">MOZ_DISABLE_CONTENT_SANDBOX</span><span style=\"color:#E1E4E8\">=1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">Environment</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#F97583\">MOZ_ENABLE_WAYLAND</span><span style=\"color:#E1E4E8\">=0</span></span></code></pre>\n<p>Now it survives reboots.</p>\n<h2>What Works Now</h2>\n<p>Reddit loads cleanly now. That was the whole point.</p>\n<p>Sessions persist too. Cookies survive restarts because <code>managed_persistence</code> keeps a browser profile on disk at <code>~/.camofox/profiles/</code>. The browser is pre-warmed when the service starts, so the first navigation isn't sluggish. I notice it most on the cold first read of the day.</p>\n<p>The core problem, Hermes flat-out refusing to load half the links I sent it, is mostly gone. A few sites still clock me occasionally, but it's the exception now rather than the default.</p>",
            "url": "https://lukemanning.ie/blog/setting-up-camoufox-with-hermes",
            "title": "Camoufox Tab Creation Kept Timing Out on Hermes",
            "summary": "<p>I'd send Hermes links to read. Reddit threads, a couple of docs sites, the occasional opinion piece. Half of them wouldn't load. Bot detection. Just blocked.</p>\n<p>(Hermes is the local agent I use for <a href=\"/blog/building-a-reading-companion-for-my-vault\">reading and research</a>. Pointing it at a URL and expecting it to come back with something useful was the whole idea.)</p>\n<p>I didn't really know where to go with it until I stumbled on a thread in the Hermes subreddit about this exact problem. People recommended <a href=\"https://camoufox.com/\">Camoufox</a>, a Firefox fork with fingerprint spoofing built in, designed to look like a real browser to detection systems. I forwarded the thread to Hermes and said let's do this.</p>\n<h2>Setting Up Camoufox</h2>\n<p>Hermes took it from there. The thread pointed at a recommended architecture: a Node.js server called <code>camofox-browser</code> that wraps Camoufox and exposes a REST API, which Hermes has native support for. Hermes pulled it down, wired up the config, and started it.</p>\n<p>Here's what it configured. In <code>~/.hermes/.env</code>:</p>\n<pre><code>CAMOFOX_URL=http://localhost:9377\nCAMOFOX_ADOPT_EXISTING_TAB=true\n</code></pre>\n<p>And in <code>~/.hermes/config.yaml</code> under <code>browser.camofox</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">camofox</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  managed_persistence</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  adopt_existing_tab</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span></code></pre>\n<p>Health check came back clean. Looked done.</p>\n<h2>Tab Creation Kept Timing Out</h2>\n<p>It wasn't done. The first time Hermes tried to actually open a tab:</p>\n<pre><code>tab create timed out after 30000ms\nbrowserContext.newPage: Target page, context or browser has been closed\n</code></pre>\n<p>The browser launched. It just wouldn't open tabs. I assumed it was a resources thing at first. Wrong direction.</p>\n<p>So I asked Hermes to go digging. It traced it through the code — <code>firefox.launch(options)</code> was succeeding, but the IPC pipe (the juggler interface Playwright uses to talk to Firefox) was being blocked. Hermes grepped the camoufox package for sandbox-related flags and found nothing in the application code, which meant the problem was at the Firefox runtime level.</p>\n<p>It was. Firefox's content sandbox, a security feature that isolates content processes, was sealing off that IPC pipe. The browser launches fine, but the moment it tries to sandbox its child content processes, the pipe gets closed and the tab request dies before it ever gets a page.</p>\n<p>Two environment variables fixed it:</p>\n<pre><code>MOZ_DISABLE_CONTENT_SANDBOX=1\nMOZ_ENABLE_WAYLAND=0\n</code></pre>\n<p><code>MOZ_DISABLE_CONTENT_SANDBOX=1</code> turns the content sandbox off, unblocking the pipe. <code>MOZ_ENABLE_WAYLAND=0</code> forces Firefox onto X11 instead of Wayland — the sandbox behaviour was worse on Wayland, or the IPC just works more reliably over X11 for headless automation. Hermes set both in the same patch and restarted.</p>\n<p>The health check came back with <code>browserConnected: true</code> and <code>browserRunning: true</code>. Tab creation worked. Felt anticlimactic after all that back and forth.</p>\n<h2>Making It Stick</h2>\n<p>The persistence instinct was familiar — I'd already <a href=\"/blog/hermes-profiles-to-docker\">containerised my Hermes profiles</a> for basically the same reason. Hermes wrapped the camofox server in a user-level systemd service (<code>~/.config/systemd/user/camofox.service</code>) so it survives reboots, carrying both environment variables.</p>\n<p>The <code>[Service]</code> block ends up looking like:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">[Service]</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">Environment</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#F97583\">MOZ_DISABLE_CONTENT_SANDBOX</span><span style=\"color:#E1E4E8\">=1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">Environment</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#F97583\">MOZ_ENABLE_WAYLAND</span><span style=\"color:#E1E4E8\">=0</span></span></code></pre>\n<p>Now it survives reboots.</p>\n<h2>What Works Now</h2>\n<p>Reddit loads cleanly now. That was the whole point.</p>\n<p>Sessions persist too. Cookies survive restarts because <code>managed_persistence</code> keeps a browser profile on disk at <code>~/.camofox/profiles/</code>. The browser is pre-warmed when the service starts, so the first navigation isn't sluggish. I notice it most on the cold first read of the day.</p>\n<p>The core problem, Hermes flat-out refusing to load half the links I sent it, is mostly gone. A few sites still clock me occasionally, but it's the exception now rather than the default.</p>",
            "date_modified": "2026-07-19T00:00:00.000Z",
            "tags": [
                "hermes"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/i-shipped-a-library-now-what",
            "content_html": "<p>I built a component library. It's called <a href=\"/projects/projex\">Projex</a>. It's on npm at @manningworks/projex. It does one thing: makes it stupidly easy to put your GitHub repos, npm packages, Product Hunt launches, Gumroad products — all of it — on a portfolio page with stat counters, links, learnings and zero manual updating.</p>\n<p>I wrote about <a href=\"/blog/building-projex-retrospective\">building Projex</a> and <a href=\"/blog/dogfooding-projex-devto\">dogfooding it on dev.to</a> if you want to read the full story.</p>\n<p>v1.3.1 is shipped as of this article. It's Public on <a href=\"https://github.com/ManningWorks/Projex\">Github</a> and <a href=\"https://www.npmjs.com/package/@manningworks/projex\">npm</a>.</p>\n<p>Over six hundred people downloaded it last month according to NPM stats.</p>\n<p>I have no idea if any of them actually used it.</p>\n<hr>\n<h2>The Problem I Was Solving</h2>\n<p>I wanted a portfolio page. I didn't want to manually update it every time I pushed a commit. I wanted live data — GitHub stars, npm downloads, that kind of thing — to just be there.</p>\n<p>I looked around. GitHub Readme Stats exists. It's just badges. README generators are for READMEs. Nothing I found did the thing I wanted: a proper portfolio page, shadcn-style, where you own the components, where the data comes from the actual APIs.</p>\n<p>So I described the problem to my AI agent and we started building.</p>\n<p>That was months ago.</p>\n<hr>\n<h2>What the Code Review Turned Up</h2>\n<p>Last week I asked my AI agent to do a proper review. Line by line through the normalisation function, the fetchers, the component API. I wanted it to evaluate code but I also wanted to consider the perspective of a user trying to implement Projex.</p>\n<p>It found twelve issues.</p>\n<p>Some of them were real problems I'd missed. The error handling was inconsistent — Zod validation throws, but API failures return null and warn. The 400-line <code>normalise</code> function does too much: validation, seven different API calls, field resolution, commits fetching. The TypeScript output types don't match runtime behavior — a <code>github</code> type project can theoretically have Lemon Squeezy revenue stats in the type system even though it never will.</p>\n<p>Twelve issues. The code still works. My own <a href=\"/projects\">Projects</a> page is proof of that.</p>\n<p>The issues were real. They were also not the problem.</p>\n<hr>\n<h2>The Question I Couldn't Answer</h2>\n<p>I was talking through the findings with my agent and it asked me something I didn't have a good answer for.</p>\n<p>\"Could you demo this in 60 seconds?\"</p>\n<p>I sat with that for a while. Here's what getting Projex running actually involves right now:</p>\n<ul>\n<li>Install it</li>\n<li>Write async data fetching code</li>\n<li>Understand that GitHub data only loads at build time, not dev time — the fetches are server-side and only run during a build, so <code>pnpm dev</code> shows empty cards until you actually build</li>\n<li>Wire up a server component for the data fetch</li>\n<li>Wire up a client component for search</li>\n<li>Deal with the server/client split</li>\n</ul>\n<p>That is not demoable.</p>\n<p>I couldn't show you what Projex does in 60 seconds because to see what it does, you have to build something with it. The value is hidden behind implementation.</p>\n<p>Six hundred installs tells me people are curious enough to try. It doesn't tell me anyone got to the part where it clicks.</p>\n<hr>\n<h2>The Thing Blocking Everything</h2>\n<p>The code isn't the blocker.</p>\n<p>The first-time experience is the blocker.</p>\n<p>If I can't show someone \"install this, add these five lines, here's your live portfolio\" in under five minutes, the demo falls apart. If the demo falls apart, Product Hunt doesn't make sense. If PH doesn't make sense, the distribution strategy doesn't work.</p>\n<p>I started picturing what the demo would actually look like. Me, a fresh Next.js project, pasting in a snippet and getting a live portfolio back. For that to work, the async fetching and the server/client split have to disappear inside one component. The person using it shouldn't have to know any of that exists.</p>\n<p>The thing I need is something like a <code>&#x3C;ProjectGrid></code> component. One component that accepts the config, handles the data fetching internally, works with just <code>&#x3C;ProjectGrid projects={projects} /></code> in a page.</p>\n<p>No manual async. No server/client split. No reading 575 lines of getting-started docs first.</p>\n<p>Just add your project details. Here's the grid. Done.</p>\n<p>Once that exists, I can screenshot it. Once I can screenshot it, I can demo it. Once I can demo it, I can ship it on PH. The chain is short but it starts with that component.</p>\n<hr>\n<h2>What I Learned Building in Public With an AI</h2>\n<p>I don't know if I'm doing this right.</p>\n<p>I describe problems to an AI agent. It suggests solutions. We iterate. Sometimes I understand why. Sometimes I just know it works.</p>\n<p>The code review was the first time I really sat with what we'd built and asked \"but is this good?\" Not \"does it work?\" I know it works. I just hadn't put much thought into whether it was good.</p>\n<p>The twelve issues were real. The agent found them faster than I would have. But the question of whether it <em>matters</em> is still mine to answer.</p>\n<p>And the answer, I think, is that the library is probably fine. The problem is I've been building a library when I should have been building an experience.</p>\n<p>I went back to the getting-started docs after that conversation. Five hundred and seventy-five lines. All necessary, as far as I could tell when I wrote each one. None of it gets a person to the moment where Projex clicks. Nobody stumbles into five hundred lines of setup and comes out thinking \"oh, this is what I needed.\"</p>\n<p>The six hundred people who installed it found it because they were already looking. They searched, they read a post, they followed a link. That's a real audience and I'm grateful for it. But they'd already decided they needed something like this before they got there.</p>\n<p>This is what I keep <a href=\"/blog/posting-into-the-void\">posting into the void</a> about. I know people downloaded it. I don't know if any of them got to the part where it clicks.</p>\n<p>Same codebase. Different demo.</p>\n<hr>\n<h2>What I'm Going to Find Out</h2>\n<p>I'm going to build the <code>&#x3C;ProjectGrid></code> component. I'm going to get it to the point where I can open a fresh Next.js project and have a live portfolio in under five minutes.</p>\n<p>Then I'm going to actually try to demo it.</p>\n<p>I don't know if it'll work. I don't know if 612 installs becomes 6,000 or stays at 612 or drops to zero.</p>\n<p>But I know the code isn't the problem anymore.</p>\n<p>I think I needed to talk to my AI agent about it to figure that out.</p>",
            "url": "https://lukemanning.ie/blog/i-shipped-a-library-now-what",
            "title": "I Shipped a Component Library. I Have No Idea If Anyone Actually Uses It.",
            "summary": "<p>I built a component library. It's called <a href=\"/projects/projex\">Projex</a>. It's on npm at @manningworks/projex. It does one thing: makes it stupidly easy to put your GitHub repos, npm packages, Product Hunt launches, Gumroad products — all of it — on a portfolio page with stat counters, links, learnings and zero manual updating.</p>\n<p>I wrote about <a href=\"/blog/building-projex-retrospective\">building Projex</a> and <a href=\"/blog/dogfooding-projex-devto\">dogfooding it on dev.to</a> if you want to read the full story.</p>\n<p>v1.3.1 is shipped as of this article. It's Public on <a href=\"https://github.com/ManningWorks/Projex\">Github</a> and <a href=\"https://www.npmjs.com/package/@manningworks/projex\">npm</a>.</p>\n<p>Over six hundred people downloaded it last month according to NPM stats.</p>\n<p>I have no idea if any of them actually used it.</p>\n<hr>\n<h2>The Problem I Was Solving</h2>\n<p>I wanted a portfolio page. I didn't want to manually update it every time I pushed a commit. I wanted live data — GitHub stars, npm downloads, that kind of thing — to just be there.</p>\n<p>I looked around. GitHub Readme Stats exists. It's just badges. README generators are for READMEs. Nothing I found did the thing I wanted: a proper portfolio page, shadcn-style, where you own the components, where the data comes from the actual APIs.</p>\n<p>So I described the problem to my AI agent and we started building.</p>\n<p>That was months ago.</p>\n<hr>\n<h2>What the Code Review Turned Up</h2>\n<p>Last week I asked my AI agent to do a proper review. Line by line through the normalisation function, the fetchers, the component API. I wanted it to evaluate code but I also wanted to consider the perspective of a user trying to implement Projex.</p>\n<p>It found twelve issues.</p>\n<p>Some of them were real problems I'd missed. The error handling was inconsistent — Zod validation throws, but API failures return null and warn. The 400-line <code>normalise</code> function does too much: validation, seven different API calls, field resolution, commits fetching. The TypeScript output types don't match runtime behavior — a <code>github</code> type project can theoretically have Lemon Squeezy revenue stats in the type system even though it never will.</p>\n<p>Twelve issues. The code still works. My own <a href=\"/projects\">Projects</a> page is proof of that.</p>\n<p>The issues were real. They were also not the problem.</p>\n<hr>\n<h2>The Question I Couldn't Answer</h2>\n<p>I was talking through the findings with my agent and it asked me something I didn't have a good answer for.</p>\n<p>\"Could you demo this in 60 seconds?\"</p>\n<p>I sat with that for a while. Here's what getting Projex running actually involves right now:</p>\n<ul>\n<li>Install it</li>\n<li>Write async data fetching code</li>\n<li>Understand that GitHub data only loads at build time, not dev time — the fetches are server-side and only run during a build, so <code>pnpm dev</code> shows empty cards until you actually build</li>\n<li>Wire up a server component for the data fetch</li>\n<li>Wire up a client component for search</li>\n<li>Deal with the server/client split</li>\n</ul>\n<p>That is not demoable.</p>\n<p>I couldn't show you what Projex does in 60 seconds because to see what it does, you have to build something with it. The value is hidden behind implementation.</p>\n<p>Six hundred installs tells me people are curious enough to try. It doesn't tell me anyone got to the part where it clicks.</p>\n<hr>\n<h2>The Thing Blocking Everything</h2>\n<p>The code isn't the blocker.</p>\n<p>The first-time experience is the blocker.</p>\n<p>If I can't show someone \"install this, add these five lines, here's your live portfolio\" in under five minutes, the demo falls apart. If the demo falls apart, Product Hunt doesn't make sense. If PH doesn't make sense, the distribution strategy doesn't work.</p>\n<p>I started picturing what the demo would actually look like. Me, a fresh Next.js project, pasting in a snippet and getting a live portfolio back. For that to work, the async fetching and the server/client split have to disappear inside one component. The person using it shouldn't have to know any of that exists.</p>\n<p>The thing I need is something like a <code>&#x3C;ProjectGrid></code> component. One component that accepts the config, handles the data fetching internally, works with just <code>&#x3C;ProjectGrid projects={projects} /></code> in a page.</p>\n<p>No manual async. No server/client split. No reading 575 lines of getting-started docs first.</p>\n<p>Just add your project details. Here's the grid. Done.</p>\n<p>Once that exists, I can screenshot it. Once I can screenshot it, I can demo it. Once I can demo it, I can ship it on PH. The chain is short but it starts with that component.</p>\n<hr>\n<h2>What I Learned Building in Public With an AI</h2>\n<p>I don't know if I'm doing this right.</p>\n<p>I describe problems to an AI agent. It suggests solutions. We iterate. Sometimes I understand why. Sometimes I just know it works.</p>\n<p>The code review was the first time I really sat with what we'd built and asked \"but is this good?\" Not \"does it work?\" I know it works. I just hadn't put much thought into whether it was good.</p>\n<p>The twelve issues were real. The agent found them faster than I would have. But the question of whether it <em>matters</em> is still mine to answer.</p>\n<p>And the answer, I think, is that the library is probably fine. The problem is I've been building a library when I should have been building an experience.</p>\n<p>I went back to the getting-started docs after that conversation. Five hundred and seventy-five lines. All necessary, as far as I could tell when I wrote each one. None of it gets a person to the moment where Projex clicks. Nobody stumbles into five hundred lines of setup and comes out thinking \"oh, this is what I needed.\"</p>\n<p>The six hundred people who installed it found it because they were already looking. They searched, they read a post, they followed a link. That's a real audience and I'm grateful for it. But they'd already decided they needed something like this before they got there.</p>\n<p>This is what I keep <a href=\"/blog/posting-into-the-void\">posting into the void</a> about. I know people downloaded it. I don't know if any of them got to the part where it clicks.</p>\n<p>Same codebase. Different demo.</p>\n<hr>\n<h2>What I'm Going to Find Out</h2>\n<p>I'm going to build the <code>&#x3C;ProjectGrid></code> component. I'm going to get it to the point where I can open a fresh Next.js project and have a live portfolio in under five minutes.</p>\n<p>Then I'm going to actually try to demo it.</p>\n<p>I don't know if it'll work. I don't know if 612 installs becomes 6,000 or stays at 612 or drops to zero.</p>\n<p>But I know the code isn't the problem anymore.</p>\n<p>I think I needed to talk to my AI agent about it to figure that out.</p>",
            "date_modified": "2026-04-17T00:00:00.000Z",
            "tags": [
                "projex"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/projex-cli-config-editor",
            "content_html": "<p>Projex is my internal tool for managing the projects page on this site. It stores all project data in <code>projex.config.ts</code>. That's by design. Type-safe, validated at build time, lives in the repo alongside the code. Works well.</p>\n<p>Until you want to add a learning entry to a project.</p>\n<p>Then you're opening a TypeScript file, finding the right project in the array, counting commas to make sure you don't mess up the object syntax, adding a new entry to the <code>struggles</code> array, making sure the date format is right, closing the brackets properly. All for something that should be one command.</p>\n<p>I built this library. I wrote the config schema. And even I was getting it wrong. I'd actually <a href=\"/blog/building-projex-retrospective\">written about Projex before</a> - you can see it on my <a href=\"/projects/projex\">projects page</a>. The dev.to integration <a href=\"/blog/dogfooding-projex-devto\">broke immediately</a> the first time I tried it. Same theme: I built it, but I wasn't actually using it like a real user would.</p>\n<hr>\n<h2>The Tipping Point</h2>\n<p>It was something small. I wanted to add a timeline entry to a project. Opened <code>projex.config.ts</code>, scrolled to the right project, added the entry. Saved. Build passed. Checked the site.</p>\n<p>The entry was on the wrong project.</p>\n<p>I'd pasted it inside the wrong object in the array. Easy mistake when your config is a nested TypeScript data structure and you're editing it like a text file. No validation catches it because the types are fine. The entry is valid. It's just... in the wrong place.</p>\n<p>That was the moment I thought: there has to be a better way.</p>\n<hr>\n<h2>The CLI</h2>\n<p>I decided to build a CLI. Not because CLIs are fun to build. Because the alternative was continuing to hand-edit a TypeScript file every time I wanted to update my projects page.</p>\n<p>The scope was straightforward. I needed commands for everything I was doing manually:</p>\n<ul>\n<li>Add and remove projects</li>\n<li>Add and remove learning entries, timeline entries, posts</li>\n<li>Edit project fields</li>\n<li>List what's in the config</li>\n</ul>\n<p>The tricky part was that <code>projex.config.ts</code> is a real TypeScript file. Not JSON. Not YAML. It uses <code>defineProjects()</code>, has imports, can have comments. I needed to parse and modify it without destroying the structure.</p>\n<p>I Googled around for TypeScript AST manipulation and found <a href=\"https://ts-morph.com/\">ts-morph</a>. It reads the file as a tree of nodes rather than raw text. Find a project by ID, add an entry to an array, change a property value. All without touching the surrounding code, comments, or formatting.</p>\n<p>I'd seen ts-morph mentioned before when I was working on something similar. It felt like the right tool for this specific job. The other option was just string manipulation, which seemed brittle.</p>\n<p>The actual manipulation was less painful than I expected. I figured I'd have to write a lot of traversal code to navigate the nested structure. But ts-morph's API handles array operations cleanly. Adding to an array, removing from an array, finding nodes by specific properties. It just worked.</p>\n<hr>\n<h2>The Bugs I Found By Testing More</h2>\n<p>The first version worked. But there were edge cases I didn't catch until I sat down and wrote tests for scenarios that seemed unlikely.</p>\n<p>The remove commands showed entries as <code>#0</code>, <code>#1</code>, <code>#2</code> in the interactive prompt. Useless. You'd have to know which index corresponded to which entry. I changed them to show the actual content. Learning entries show <code>[challenge] Struggled with state management...</code>. Timeline entries show <code>2026-04-15 - v1.0 released</code>. Posts show the title and date. Obvious in retrospect.</p>\n<p>Then there was a subtler bug. The code that reads entries from the config filters for object literals in the array. If someone had a spread element in there, like <code>[...sharedEntries, { type: 'challenge', text: 'actual entry' }]</code>, the filtered list would skip the spread. But the index reported to the user would be wrong. They'd pick what looked like entry 0, but the actual array index was 1. The wrong entry would get deleted silently.</p>\n<p>Fixed that by tracking original indices through the parsed structure instead of using the filtered list position. Added an integration test with a spread element to prove it works.</p>\n<p>The edit command had its own issue. It let you set any field on any project type with just a warning. <code>--channel-id</code> on a GitHub project? Sure, warned and proceeded. That's not a warning situation. That's a \"you're doing something wrong\" situation. Changed it to error and exit.</p>\n<hr>\n<h2>The --unset Flag</h2>\n<p>The one feature I didn't originally plan for was removing fields. The CLI could add, edit, and remove projects and entries. But if you accidentally set a field that shouldn't be there, you were back to editing the config file manually.</p>\n<p>That defeated the purpose.</p>\n<p>So I added <code>--unset</code>. <code>projex edit project my-project --unset description</code> removes the field entirely. Protected fields like <code>id</code>, <code>type</code>, and the array fields can't be removed. It can't be combined with other edit flags either, because that would be ambiguous.</p>\n<p>Simple feature. But without it, the CLI wasn't complete. You'd still need to touch the config file for at least one class of changes.</p>\n<hr>\n<h2>Where Things Stand</h2>\n<p>881 tests. 55 test files. The integration tests create actual temporary config files and exercise the real parsing logic, not just mocked versions of it.</p>\n<p>The CLI handles init, add, edit, remove, and list. Interactive mode when you don't provide flags, non-interactive mode with flags for scripting. Type-specific field validation that errors instead of warns. Descriptive labels on remove prompts instead of index numbers.</p>\n<p>I've been using it for a day and it's already changed how I interact with my projects page. Adding a learning entry went from \"open file, find project, count commas, hope for the best\" to:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">projex</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> learning</span><span style=\"color:#9ECBFF\"> projex</span><span style=\"color:#79B8FF\"> --type</span><span style=\"color:#9ECBFF\"> challenge</span><span style=\"color:#79B8FF\"> --text</span><span style=\"color:#9ECBFF\"> \"Config file parsing is surprisingly tricky\"</span></span></code></pre>",
            "url": "https://lukemanning.ie/blog/projex-cli-config-editor",
            "title": "I Got Tired of Editing the Projex Config File So I Built a CLI",
            "summary": "<p>Projex is my internal tool for managing the projects page on this site. It stores all project data in <code>projex.config.ts</code>. That's by design. Type-safe, validated at build time, lives in the repo alongside the code. Works well.</p>\n<p>Until you want to add a learning entry to a project.</p>\n<p>Then you're opening a TypeScript file, finding the right project in the array, counting commas to make sure you don't mess up the object syntax, adding a new entry to the <code>struggles</code> array, making sure the date format is right, closing the brackets properly. All for something that should be one command.</p>\n<p>I built this library. I wrote the config schema. And even I was getting it wrong. I'd actually <a href=\"/blog/building-projex-retrospective\">written about Projex before</a> - you can see it on my <a href=\"/projects/projex\">projects page</a>. The dev.to integration <a href=\"/blog/dogfooding-projex-devto\">broke immediately</a> the first time I tried it. Same theme: I built it, but I wasn't actually using it like a real user would.</p>\n<hr>\n<h2>The Tipping Point</h2>\n<p>It was something small. I wanted to add a timeline entry to a project. Opened <code>projex.config.ts</code>, scrolled to the right project, added the entry. Saved. Build passed. Checked the site.</p>\n<p>The entry was on the wrong project.</p>\n<p>I'd pasted it inside the wrong object in the array. Easy mistake when your config is a nested TypeScript data structure and you're editing it like a text file. No validation catches it because the types are fine. The entry is valid. It's just... in the wrong place.</p>\n<p>That was the moment I thought: there has to be a better way.</p>\n<hr>\n<h2>The CLI</h2>\n<p>I decided to build a CLI. Not because CLIs are fun to build. Because the alternative was continuing to hand-edit a TypeScript file every time I wanted to update my projects page.</p>\n<p>The scope was straightforward. I needed commands for everything I was doing manually:</p>\n<ul>\n<li>Add and remove projects</li>\n<li>Add and remove learning entries, timeline entries, posts</li>\n<li>Edit project fields</li>\n<li>List what's in the config</li>\n</ul>\n<p>The tricky part was that <code>projex.config.ts</code> is a real TypeScript file. Not JSON. Not YAML. It uses <code>defineProjects()</code>, has imports, can have comments. I needed to parse and modify it without destroying the structure.</p>\n<p>I Googled around for TypeScript AST manipulation and found <a href=\"https://ts-morph.com/\">ts-morph</a>. It reads the file as a tree of nodes rather than raw text. Find a project by ID, add an entry to an array, change a property value. All without touching the surrounding code, comments, or formatting.</p>\n<p>I'd seen ts-morph mentioned before when I was working on something similar. It felt like the right tool for this specific job. The other option was just string manipulation, which seemed brittle.</p>\n<p>The actual manipulation was less painful than I expected. I figured I'd have to write a lot of traversal code to navigate the nested structure. But ts-morph's API handles array operations cleanly. Adding to an array, removing from an array, finding nodes by specific properties. It just worked.</p>\n<hr>\n<h2>The Bugs I Found By Testing More</h2>\n<p>The first version worked. But there were edge cases I didn't catch until I sat down and wrote tests for scenarios that seemed unlikely.</p>\n<p>The remove commands showed entries as <code>#0</code>, <code>#1</code>, <code>#2</code> in the interactive prompt. Useless. You'd have to know which index corresponded to which entry. I changed them to show the actual content. Learning entries show <code>[challenge] Struggled with state management...</code>. Timeline entries show <code>2026-04-15 - v1.0 released</code>. Posts show the title and date. Obvious in retrospect.</p>\n<p>Then there was a subtler bug. The code that reads entries from the config filters for object literals in the array. If someone had a spread element in there, like <code>[...sharedEntries, { type: 'challenge', text: 'actual entry' }]</code>, the filtered list would skip the spread. But the index reported to the user would be wrong. They'd pick what looked like entry 0, but the actual array index was 1. The wrong entry would get deleted silently.</p>\n<p>Fixed that by tracking original indices through the parsed structure instead of using the filtered list position. Added an integration test with a spread element to prove it works.</p>\n<p>The edit command had its own issue. It let you set any field on any project type with just a warning. <code>--channel-id</code> on a GitHub project? Sure, warned and proceeded. That's not a warning situation. That's a \"you're doing something wrong\" situation. Changed it to error and exit.</p>\n<hr>\n<h2>The --unset Flag</h2>\n<p>The one feature I didn't originally plan for was removing fields. The CLI could add, edit, and remove projects and entries. But if you accidentally set a field that shouldn't be there, you were back to editing the config file manually.</p>\n<p>That defeated the purpose.</p>\n<p>So I added <code>--unset</code>. <code>projex edit project my-project --unset description</code> removes the field entirely. Protected fields like <code>id</code>, <code>type</code>, and the array fields can't be removed. It can't be combined with other edit flags either, because that would be ambiguous.</p>\n<p>Simple feature. But without it, the CLI wasn't complete. You'd still need to touch the config file for at least one class of changes.</p>\n<hr>\n<h2>Where Things Stand</h2>\n<p>881 tests. 55 test files. The integration tests create actual temporary config files and exercise the real parsing logic, not just mocked versions of it.</p>\n<p>The CLI handles init, add, edit, remove, and list. Interactive mode when you don't provide flags, non-interactive mode with flags for scripting. Type-specific field validation that errors instead of warns. Descriptive labels on remove prompts instead of index numbers.</p>\n<p>I've been using it for a day and it's already changed how I interact with my projects page. Adding a learning entry went from \"open file, find project, count commas, hope for the best\" to:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">projex</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> learning</span><span style=\"color:#9ECBFF\"> projex</span><span style=\"color:#79B8FF\"> --type</span><span style=\"color:#9ECBFF\"> challenge</span><span style=\"color:#79B8FF\"> --text</span><span style=\"color:#9ECBFF\"> \"Config file parsing is surprisingly tricky\"</span></span></code></pre>",
            "date_modified": "2026-04-16T00:00:00.000Z",
            "tags": [
                "projex"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/dogfooding-projex-devto",
            "content_html": "<p>I wanted to add my dev.to profile to my projects page. Simple enough. Projex, my own library, has a <code>devto</code> type built in. I wrote the code. Added it to <code>projex.config.ts</code>. Checked the page.</p>\n<p>No stats. Nothing. Just an empty project card staring back at me.</p>\n<hr>\n<h2>The Setup</h2>\n<p>Projex supports nine project types. GitHub, npm, Product Hunt, dev.to, and a few others. Each type fetches data from a different API and normalises it into a standard format. Stats, links, descriptions.</p>\n<p>I'd tested GitHub and hybrid (GitHub + npm) types extensively. Those are what I use for my other projects. The dev.to type had been sitting there since I shipped it. Never actually used it.</p>\n<p>So I added this to my config:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  id</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'devto'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'dev.to/manningworks'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  type</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'devto'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  username</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'manningworks'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  status</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'active'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ... background, timeline, all the usual fields</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Hit the projects page. The card rendered fine. Name, description, timeline, all good. But the stats section was completely empty. No articles. No views. No reactions.</p>\n<p>My first thought was that the API call was failing silently. Maybe a network error I wasn't catching, or the username was wrong somehow. I checked the <code>npm run dev</code> console logs. Nothing. The fetch had run fine. One article, valid JSON. The data was there.</p>\n<p>So the fetch was working. Something else was wrong.</p>\n<hr>\n<h2>Layer One: Wrong API Field Names</h2>\n<p>I opened the API response side by side with the Projex source code. That's when I saw it.</p>\n<p>Here's what Projex's <code>fetchDevToUser</code> was doing:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> totalViews</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> data.</span><span style=\"color:#B392F0\">reduce</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  (</span><span style=\"color:#FFAB70\">sum</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">article</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> sum </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> article.page_views_count, </span><span style=\"color:#79B8FF\">0</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> totalReactions</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> data.</span><span style=\"color:#B392F0\">reduce</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  (</span><span style=\"color:#FFAB70\">sum</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">article</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> sum </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> article.positive_reactions_count, </span><span style=\"color:#79B8FF\">0</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">);</span></span></code></pre>\n<p>And here's what the live API response actually contained: <code>public_reactions_count</code> and no <code>page_views_count</code> at all.</p>\n<p>Two problems.</p>\n<p><code>page_views_count</code> is only returned on authenticated requests. The public API doesn't include it. So it was <code>undefined</code>, and <code>0 + undefined = NaN</code>.</p>\n<p><code>positive_reactions_count</code> was deprecated by dev.to in favour of <code>public_reactions_count</code>. The public response returns <code>public_reactions_count</code>, not <code>positive_reactions_count</code>.</p>\n<p>So both values were <code>NaN</code>. The stats existed but were garbage. The component saw <code>NaN !== undefined</code> as true, tried to render them, and... nothing rendered properly.</p>\n<p>Classic. I'd written code against API docs without actually checking what the live response looks like.</p>\n<p>Raised an issue on my own repo. Fixed the field names. Shipped Projex 1.2.0.</p>\n<p>Then I updated my site to 1.2.0, reloaded the page, and... still no stats. Different reason this time, but same empty card.</p>\n<hr>\n<h2>Layer Two: Missing Render Block</h2>\n<p>But that wasn't the only problem. Even if the stats had been correct from the start, they still wouldn't have shown up.</p>\n<p>I started digging through <code>ProjectDetail.tsx</code>, the component that renders project cards. It had render blocks for GitHub stats (stars, forks), npm stats (downloads, version), and Product Hunt stats (upvotes, comments).</p>\n<p>No dev.to block. At all.</p>\n<p>The component knew about three stat types. Projex supported nine project types. I'd never added the render logic for the newer ones because I'd never needed it. The data was there, normalised and everything. The component just had no idea what to do with it.</p>\n<p>So I added a dev.to section:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{project.stats.articleCount </span><span style=\"color:#F97583\">!==</span><span style=\"color:#79B8FF\"> undefined</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#6A737D\"> /* ... */</span><span style=\"color:#F97583\"> ?</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mb-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">span</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-xs text-terminal-dim\"</span><span style=\"color:#E1E4E8\">>dev.to: &#x3C;/</span><span style=\"color:#85E89D\">span</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"flex flex-wrap gap-2 mt-2\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      {</span><span style=\"color:#6A737D\">/* articles, views, reactions */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> null</span><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Same pattern as the other stat blocks. Nothing clever.</p>\n<p>Saved it. Reloaded. Articles showed up. Views showed <code>0</code>. Reactions were still missing. Of course they were.</p>\n<hr>\n<h2>Layer Three: Renamed Property</h2>\n<p>The 1.2.0 release renamed <code>averageReactions</code> to <code>totalReactions</code> (separate fix, separate issue). But my component was still checking for <code>averageReactions</code>. TypeScript caught it immediately — red squiggle, clear error. Which was nice, but still. Three separate issues stacked on top of each other.</p>\n<p>Fixed the property name in the component. Reactions showed up. <code>0</code> reactions, which is accurate since I'd just published my first post.</p>\n<hr>\n<h2>Layer Four: Views Need an API Key</h2>\n<p>Views were showing <code>0</code>. The dev.to public API doesn't return <code>page_views_count</code>. The 1.2.0 fix added support for <code>DEV_TO_API_KEY</code> as an environment variable. If set, the library sends it as an <code>api-key</code> header, and dev.to returns view counts.</p>\n<p>I have a dev.to API key. Just need to add it to <code>.env.local</code>.</p>\n<p>That's a config step, not a code fix. But it's another thing I wouldn't have known about without actually trying to use the feature.</p>\n<hr>\n<h2>Tests Passed. Nothing Worked.</h2>\n<p>Projex has tests. It has Zod schema validation. The types all check out. None of that caught any of this.</p>\n<p>The dev.to integration passed validation because the schema was correct. The types were correct. The API call was going to the right endpoint. The code was doing exactly what it was written to do. It was just written against the wrong field names and the component had no way to display the results.</p>\n<p>Four separate issues. All invisible until I actually used the thing.</p>\n<p>I <a href=\"/blog/building-projex-retrospective\">wrote a retrospective</a> about shipping this library. Published it on npm. And the first time I tried to use a feature I hadn't personally tested, it fell apart immediately.</p>\n<p>That's dogfooding. Not the fun kind where your product works great and you feel smug. The kind where you realise your test coverage gave you false confidence and the only way to find real problems is to actually run the software yourself.</p>\n<p>Projex 1.2.0 is out now. Dev.to integration works. I know because I'm using it.</p>\n<hr>\n<p>The four issues, if you're keeping score:</p>\n<ol>\n<li><code>page_views_count</code> not in public API response (needs auth)</li>\n<li><code>positive_reactions_count</code> deprecated, should be <code>public_reactions_count</code></li>\n<li>No render block in the component for dev.to stats</li>\n<li><code>averageReactions</code> renamed to <code>totalReactions</code>, component not updated</li>\n</ol>\n<p>All found by trying to add one project to my own site.</p>",
            "url": "https://lukemanning.ie/blog/dogfooding-projex-devto",
            "title": "Dogfooding Projex: My Own Library Broke on the First New Feature I Tried",
            "summary": "<p>I wanted to add my dev.to profile to my projects page. Simple enough. Projex, my own library, has a <code>devto</code> type built in. I wrote the code. Added it to <code>projex.config.ts</code>. Checked the page.</p>\n<p>No stats. Nothing. Just an empty project card staring back at me.</p>\n<hr>\n<h2>The Setup</h2>\n<p>Projex supports nine project types. GitHub, npm, Product Hunt, dev.to, and a few others. Each type fetches data from a different API and normalises it into a standard format. Stats, links, descriptions.</p>\n<p>I'd tested GitHub and hybrid (GitHub + npm) types extensively. Those are what I use for my other projects. The dev.to type had been sitting there since I shipped it. Never actually used it.</p>\n<p>So I added this to my config:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  id</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'devto'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'dev.to/manningworks'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  type</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'devto'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  username</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'manningworks'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  status</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'active'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ... background, timeline, all the usual fields</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Hit the projects page. The card rendered fine. Name, description, timeline, all good. But the stats section was completely empty. No articles. No views. No reactions.</p>\n<p>My first thought was that the API call was failing silently. Maybe a network error I wasn't catching, or the username was wrong somehow. I checked the <code>npm run dev</code> console logs. Nothing. The fetch had run fine. One article, valid JSON. The data was there.</p>\n<p>So the fetch was working. Something else was wrong.</p>\n<hr>\n<h2>Layer One: Wrong API Field Names</h2>\n<p>I opened the API response side by side with the Projex source code. That's when I saw it.</p>\n<p>Here's what Projex's <code>fetchDevToUser</code> was doing:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> totalViews</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> data.</span><span style=\"color:#B392F0\">reduce</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  (</span><span style=\"color:#FFAB70\">sum</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">article</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> sum </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> article.page_views_count, </span><span style=\"color:#79B8FF\">0</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> totalReactions</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> data.</span><span style=\"color:#B392F0\">reduce</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  (</span><span style=\"color:#FFAB70\">sum</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">article</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> sum </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> article.positive_reactions_count, </span><span style=\"color:#79B8FF\">0</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">);</span></span></code></pre>\n<p>And here's what the live API response actually contained: <code>public_reactions_count</code> and no <code>page_views_count</code> at all.</p>\n<p>Two problems.</p>\n<p><code>page_views_count</code> is only returned on authenticated requests. The public API doesn't include it. So it was <code>undefined</code>, and <code>0 + undefined = NaN</code>.</p>\n<p><code>positive_reactions_count</code> was deprecated by dev.to in favour of <code>public_reactions_count</code>. The public response returns <code>public_reactions_count</code>, not <code>positive_reactions_count</code>.</p>\n<p>So both values were <code>NaN</code>. The stats existed but were garbage. The component saw <code>NaN !== undefined</code> as true, tried to render them, and... nothing rendered properly.</p>\n<p>Classic. I'd written code against API docs without actually checking what the live response looks like.</p>\n<p>Raised an issue on my own repo. Fixed the field names. Shipped Projex 1.2.0.</p>\n<p>Then I updated my site to 1.2.0, reloaded the page, and... still no stats. Different reason this time, but same empty card.</p>\n<hr>\n<h2>Layer Two: Missing Render Block</h2>\n<p>But that wasn't the only problem. Even if the stats had been correct from the start, they still wouldn't have shown up.</p>\n<p>I started digging through <code>ProjectDetail.tsx</code>, the component that renders project cards. It had render blocks for GitHub stats (stars, forks), npm stats (downloads, version), and Product Hunt stats (upvotes, comments).</p>\n<p>No dev.to block. At all.</p>\n<p>The component knew about three stat types. Projex supported nine project types. I'd never added the render logic for the newer ones because I'd never needed it. The data was there, normalised and everything. The component just had no idea what to do with it.</p>\n<p>So I added a dev.to section:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{project.stats.articleCount </span><span style=\"color:#F97583\">!==</span><span style=\"color:#79B8FF\"> undefined</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#6A737D\"> /* ... */</span><span style=\"color:#F97583\"> ?</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mb-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">span</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-xs text-terminal-dim\"</span><span style=\"color:#E1E4E8\">>dev.to: &#x3C;/</span><span style=\"color:#85E89D\">span</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"flex flex-wrap gap-2 mt-2\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      {</span><span style=\"color:#6A737D\">/* articles, views, reactions */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> null</span><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Same pattern as the other stat blocks. Nothing clever.</p>\n<p>Saved it. Reloaded. Articles showed up. Views showed <code>0</code>. Reactions were still missing. Of course they were.</p>\n<hr>\n<h2>Layer Three: Renamed Property</h2>\n<p>The 1.2.0 release renamed <code>averageReactions</code> to <code>totalReactions</code> (separate fix, separate issue). But my component was still checking for <code>averageReactions</code>. TypeScript caught it immediately — red squiggle, clear error. Which was nice, but still. Three separate issues stacked on top of each other.</p>\n<p>Fixed the property name in the component. Reactions showed up. <code>0</code> reactions, which is accurate since I'd just published my first post.</p>\n<hr>\n<h2>Layer Four: Views Need an API Key</h2>\n<p>Views were showing <code>0</code>. The dev.to public API doesn't return <code>page_views_count</code>. The 1.2.0 fix added support for <code>DEV_TO_API_KEY</code> as an environment variable. If set, the library sends it as an <code>api-key</code> header, and dev.to returns view counts.</p>\n<p>I have a dev.to API key. Just need to add it to <code>.env.local</code>.</p>\n<p>That's a config step, not a code fix. But it's another thing I wouldn't have known about without actually trying to use the feature.</p>\n<hr>\n<h2>Tests Passed. Nothing Worked.</h2>\n<p>Projex has tests. It has Zod schema validation. The types all check out. None of that caught any of this.</p>\n<p>The dev.to integration passed validation because the schema was correct. The types were correct. The API call was going to the right endpoint. The code was doing exactly what it was written to do. It was just written against the wrong field names and the component had no way to display the results.</p>\n<p>Four separate issues. All invisible until I actually used the thing.</p>\n<p>I <a href=\"/blog/building-projex-retrospective\">wrote a retrospective</a> about shipping this library. Published it on npm. And the first time I tried to use a feature I hadn't personally tested, it fell apart immediately.</p>\n<p>That's dogfooding. Not the fun kind where your product works great and you feel smug. The kind where you realise your test coverage gave you false confidence and the only way to find real problems is to actually run the software yourself.</p>\n<p>Projex 1.2.0 is out now. Dev.to integration works. I know because I'm using it.</p>\n<hr>\n<p>The four issues, if you're keeping score:</p>\n<ol>\n<li><code>page_views_count</code> not in public API response (needs auth)</li>\n<li><code>positive_reactions_count</code> deprecated, should be <code>public_reactions_count</code></li>\n<li>No render block in the component for dev.to stats</li>\n<li><code>averageReactions</code> renamed to <code>totalReactions</code>, component not updated</li>\n</ol>\n<p>All found by trying to add one project to my own site.</p>",
            "date_modified": "2026-04-15T00:00:00.000Z",
            "tags": [
                "projex"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/fluorescent-office-essay",
            "content_html": "<p>An essay is making the rounds on X: <a href=\"https://open.substack.com/pub/gregscaduto/p/you-were-never-meant-to-love-your\">the one about not following your passion</a>. You may have seen it. Beautifully written, controlled prose, specific imagery, and a genuine emotional core. It argues that \"follow your passion\" is bad advice for most people; that the office with fluorescent lights that smells of burned coffee is the shape of most lives, and that the real source of meaning isn't the work itself but the people beside you.</p>\n<p>I read it and I had surprisingly visceral reaction. It took me a while to figure out why because the reaction wasn't entirely fair.</p>\n<p>Most of what it says is true. Follow your passion is bad advice. Most passions aren't economically viable. Work rarely delivers meaning on its own. The office with the humming lights and the chair that never adjusts right.. that's real. I've sat in that chair. I've had the exact conversation the essay describes, where a colleague swivels in his chair with a look on his face that says <em>I cannot believe this is my life</em>.</p>\n<p>So why did reading it bother me so much?</p>\n<p>Because the essay never distinguishes between accepting your circumstances and choosing them.</p>\n<p>It treats the office with fluorescent lights as fate. As the shape of a life, inevitable and fixed, something to be made peace with. And from that premise it draws a reasonable conclusion; if this is where you are, here's how to find meaning in it. But the framing quietly does something else too. By never acknowledging that the office is a starting point rather than a final destination, it removes the question entirely. It doesn't say you can't build something outside of it. It just doesn't leave room for the thought. The world of the essay has two exits. You either accept the ordinary life with grace, or chase your impossible-to-reach dreams and fail. That's not the full map.</p>\n<p>There's a third path. Not the rockstar path. Not the path where you sell out arenas or throw touchdown passes or have your face on a billboard. That path is as unlikely as the essay says it is. But between \"accept your future lies in an office\" and \"become exceptional\" there's a quieter option. Build small things, outside of hours, that might one day compound into something that gives you a choice you didn't have before. Start a personal brand. Build a network of connections. Open the door to alternative opportunities.</p>\n<p>I'm doing this. Slowly, imperfectly, alongside a full-time job. This blog, a couple of side projects I'm not sure will go anywhere. Some won't, and that uncertainty isn't a flaw in the plan. It's the nature of the path. You build anyway, because the alternative is to decide in advance that the office is the answer, which is exactly the move the essay is quietly recommending.</p>\n<p>That's where it loses me. Not in its compassion. The argument for finding meaning in the people beside you is real and I believe it. Not in its realism about passion. That's a correction our culture genuinely needs. It loses me in its finality. The essay is written for someone who has already stopped. For that person, the advice might be exactly right. But it presents itself as wisdom for everyone and wisdom that doesn't leave room for agency isn't wisdom. It's consolation.</p>\n<p>You can find meaning in the people beside you and still refuse to treat where you are as where you'll always be. Those aren't in conflict. The essay just forgot to say so.</p>\n<p>That distinction, between accepting where you are and deciding it's permanent, is the one I keep turning over. I'm not ready to settle. Not yet.</p>",
            "url": "https://lukemanning.ie/blog/fluorescent-office-essay",
            "title": "The Essay Everyone Is Sharing Left One Thing Out",
            "summary": "<p>An essay is making the rounds on X: <a href=\"https://open.substack.com/pub/gregscaduto/p/you-were-never-meant-to-love-your\">the one about not following your passion</a>. You may have seen it. Beautifully written, controlled prose, specific imagery, and a genuine emotional core. It argues that \"follow your passion\" is bad advice for most people; that the office with fluorescent lights that smells of burned coffee is the shape of most lives, and that the real source of meaning isn't the work itself but the people beside you.</p>\n<p>I read it and I had surprisingly visceral reaction. It took me a while to figure out why because the reaction wasn't entirely fair.</p>\n<p>Most of what it says is true. Follow your passion is bad advice. Most passions aren't economically viable. Work rarely delivers meaning on its own. The office with the humming lights and the chair that never adjusts right.. that's real. I've sat in that chair. I've had the exact conversation the essay describes, where a colleague swivels in his chair with a look on his face that says <em>I cannot believe this is my life</em>.</p>\n<p>So why did reading it bother me so much?</p>\n<p>Because the essay never distinguishes between accepting your circumstances and choosing them.</p>\n<p>It treats the office with fluorescent lights as fate. As the shape of a life, inevitable and fixed, something to be made peace with. And from that premise it draws a reasonable conclusion; if this is where you are, here's how to find meaning in it. But the framing quietly does something else too. By never acknowledging that the office is a starting point rather than a final destination, it removes the question entirely. It doesn't say you can't build something outside of it. It just doesn't leave room for the thought. The world of the essay has two exits. You either accept the ordinary life with grace, or chase your impossible-to-reach dreams and fail. That's not the full map.</p>\n<p>There's a third path. Not the rockstar path. Not the path where you sell out arenas or throw touchdown passes or have your face on a billboard. That path is as unlikely as the essay says it is. But between \"accept your future lies in an office\" and \"become exceptional\" there's a quieter option. Build small things, outside of hours, that might one day compound into something that gives you a choice you didn't have before. Start a personal brand. Build a network of connections. Open the door to alternative opportunities.</p>\n<p>I'm doing this. Slowly, imperfectly, alongside a full-time job. This blog, a couple of side projects I'm not sure will go anywhere. Some won't, and that uncertainty isn't a flaw in the plan. It's the nature of the path. You build anyway, because the alternative is to decide in advance that the office is the answer, which is exactly the move the essay is quietly recommending.</p>\n<p>That's where it loses me. Not in its compassion. The argument for finding meaning in the people beside you is real and I believe it. Not in its realism about passion. That's a correction our culture genuinely needs. It loses me in its finality. The essay is written for someone who has already stopped. For that person, the advice might be exactly right. But it presents itself as wisdom for everyone and wisdom that doesn't leave room for agency isn't wisdom. It's consolation.</p>\n<p>You can find meaning in the people beside you and still refuse to treat where you are as where you'll always be. Those aren't in conflict. The essay just forgot to say so.</p>\n<p>That distinction, between accepting where you are and deciding it's permanent, is the one I keep turning over. I'm not ready to settle. Not yet.</p>",
            "date_modified": "2026-04-14T00:00:00.000Z",
            "tags": [
                "reflections"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/building-straico-api-proxy",
            "content_html": "<p>I use <a href=\"/blog/opencode-nested-slash-commands-architecture\">OpenCode</a> as my main AI coding tool. I switched from Claude Code after <a href=\"/blog/anthropic-open-source-walled-garden-clawdbot-opencode\">Anthropic started going after open source projects</a> and I kept hitting session limits on my subscription.</p>\n<p>OpenCode works with any OpenAI-compatible API. Straico gives me access to Claude, GPT, Gemini, DeepSeek, and a bunch more through a single API key. Cheap too. Problem is, Straico's API is missing two things OpenCode needs: streaming responses and function calling.</p>\n<p>Without streaming, OpenCode just hangs. Never gets a response. But Straico keeps eating tokens on their end anyway. Without function calling, the AI can't use tools like reading files or running bash commands. Both are non-negotiable for an agentic coding tool.</p>\n<p>So I built a proxy. It sits between OpenCode and Straico, translating requests and responses to fill in the gaps.</p>\n<pre><code>OpenCode\n  → localhost:8000 (my proxy)\n    → Straico API\n</code></pre>\n<p>What started as \"just simulate streaming and inject tool definitions\" turned into a surprisingly full-featured thing. The codebase is at <a href=\"https://github.com/ManningWorks/DOAI-Proxy\">github.com/ManningWorks/DOAI-Proxy</a>.</p>\n<h2>The Architecture I Ended Up With</h2>\n<p>I didn't start with a provider pattern. I started with four files: <code>server.js</code>, <code>streaming.js</code>, <code>tools.js</code>, <code>utils.js</code>. But once I started thinking about adding other providers down the line (OpenAI direct, Anthropic direct), I refactored into something cleaner.</p>\n<p>The provider pattern lives in <code>providers/</code>. <code>BaseProvider</code> is an abstract class that handles the interface contract and retry logic. <code>StraicoProvider</code> extends it with Straico-specific request/response transformation. <code>ProviderFactory</code> instantiates the right one based on the <code>PROVIDER_TYPE</code> env var.</p>\n<p>Right now only Straico exists, but the factory already has stubs for OpenAI and Anthropic. The <code>ADDING_PROVIDERS.md</code> doc in the repo lays out how to add a new one.</p>\n<p>The other modules:</p>\n<ul>\n<li><code>server.js</code> - Express server, routing, auth, request lifecycle</li>\n<li><code>streaming.js</code> - SSE simulation with two modes</li>\n<li><code>tools.js</code> - Tool injection and response parsing</li>\n<li><code>utils.js</code> - Logging, formatting, log rotation</li>\n<li><code>utils/model-limits.js</code> - Fetches context limits from Straico's API</li>\n<li><code>summarizer.js</code> - Conversation summarization for long sessions</li>\n<li><code>scripts/sync-opencode-config.js</code> - Syncs model list to OpenCode config</li>\n</ul>\n<h2>Streaming Without Streaming</h2>\n<p>Straico returns the full response at once. No SSE. No chunks. The proxy has to fake it.</p>\n<p>Two modes: <code>none</code> and <code>smart</code>.</p>\n<p><code>none</code> is what I'd recommend as default. It sends the entire response in one SSE chunk, then the <code>[DONE]</code> marker. Fast, no formatting issues, still technically SSE.</p>\n<p><code>smart</code> is more interesting. It splits the response into chunks with delays to simulate real streaming. The naive approach is <code>responseText.match(new RegExp('.{1,15}', 'g'))</code> and that kind of works. But it breaks markdown. Split mid-bold, mid-code-block, mid-backtick and the rendering glitches.</p>\n<p>So <code>smartChunkText()</code> in <code>streaming.js</code> looks for safe boundaries. It prefers splitting on newlines, then whitespace. It also checks for markdown delimiters (<code>**</code>, <code>__</code>, <code>\\</code>``<code>, `` </code> ``) and extends the chunk to avoid splitting them. There's a max size limit (<code>targetSize * 10</code>) to prevent infinite extension.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// streaming.js - simplified version of the boundary logic</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">let</span><span style=\"color:#E1E4E8\"> i </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> end; i </span><span style=\"color:#F97583\">></span><span style=\"color:#E1E4E8\"> start; i</span><span style=\"color:#F97583\">--</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (text[i] </span><span style=\"color:#F97583\">===</span><span style=\"color:#9ECBFF\"> '</span><span style=\"color:#79B8FF\">\\n</span><span style=\"color:#9ECBFF\">'</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> i </span><span style=\"color:#F97583\">+</span><span style=\"color:#79B8FF\"> 1</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> delim</span><span style=\"color:#F97583\"> of</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#9ECBFF\">'**'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'__'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'```'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'`'</span><span style=\"color:#E1E4E8\">]) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> delimStart</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> text.</span><span style=\"color:#B392F0\">indexOf</span><span style=\"color:#E1E4E8\">(delim, start);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (delimStart </span><span style=\"color:#F97583\">!==</span><span style=\"color:#F97583\"> -</span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> delimStart </span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#E1E4E8\"> end) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#79B8FF\"> delimEnd</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> delimStart </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> delim.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (delimEnd </span><span style=\"color:#F97583\">></span><span style=\"color:#E1E4E8\"> end) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      return</span><span style=\"color:#E1E4E8\"> Math.</span><span style=\"color:#B392F0\">min</span><span style=\"color:#E1E4E8\">(delimEnd, text.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">, start </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> maxSize);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Default is 15 characters per chunk with 80ms delay. That feels about right for most models. Configurable via <code>STREAM_CHUNK_SIZE</code> and <code>STREAM_DELAY_MS</code> env vars.</p>\n<p>I set <code>STREAM_MODE=none</code> as the recommended default. <code>smart</code> works but it's more of a showcase thing. The boundary detection catches most cases but I wouldn't trust it with complex nested markdown.</p>\n<h2>Function Calling via Prompt Injection</h2>\n<p>Straico doesn't support function calling natively. The workaround: inject tool definitions into the system prompt and parse the AI's response to detect tool calls.</p>\n<p><code>injectToolsIntoSystem()</code> in <code>tools.js</code> appends a formatted list of available tools to the system message:</p>\n<pre><code>You have access to the following tools:\n- bash: Run bash commands\n- read: Read file contents\n\nWhen you need to use a tool, format your response like this:\nTOOL_CALL: &#x3C;tool_name>\nARGUMENTS: &#x3C;json_arguments>\n</code></pre>\n<p>There's a sentinel comment (<code>&#x3C;!-- proxy-tools-injected --></code>) to prevent double-injection if the same messages get processed twice.</p>\n<p>The tricky part is parsing. Different models output tool calls in different formats. I ended up with four parsers that run in sequence:</p>\n<ol>\n<li><strong>Minimax XML</strong> - <code>&#x3C;minimax:tool_call></code> with <code>&#x3C;invoke></code> tags</li>\n<li><strong>Claude XML</strong> - <code>&#x3C;invoke name=\"...\"></code> with <code>&#x3C;parameter_list></code> tags</li>\n<li><strong>OpenAI Native</strong> - JSON with <code>\"tool_calls\": [...]</code> embedded in the response</li>\n<li><strong>Text Format</strong> - The <code>TOOL_CALL: / ARGUMENTS:</code> format from the injection prompt</li>\n</ol>\n<p>Each parser tries to extract tool calls from the response text. The first one that succeeds wins. This was a gradual thing. I started with just the text format parser. Then Minimax models returned XML. Then Claude models returned different XML. Then some models returned JSON that looked like OpenAI's format. Four parsers later and it handles most cases.</p>\n<p>The text format parser was the hardest to get right. Matching <code>TOOL_CALL: tool_name ARGUMENTS: {json}</code> seems simple until the JSON contains nested objects, strings with braces, or the model forgets the space between the tool name and ARGUMENTS. The implementation tracks brace depth to find where the JSON actually ends:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">let</span><span style=\"color:#E1E4E8\"> i </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> argsStartIndex; i </span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#E1E4E8\"> responseText.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">; i</span><span style=\"color:#F97583\">++</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> char</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> responseText[i];</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (char </span><span style=\"color:#F97583\">===</span><span style=\"color:#9ECBFF\"> '{'</span><span style=\"color:#E1E4E8\">) braceCount</span><span style=\"color:#F97583\">++</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  else</span><span style=\"color:#F97583\"> if</span><span style=\"color:#E1E4E8\"> (char </span><span style=\"color:#F97583\">===</span><span style=\"color:#9ECBFF\"> '}'</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    braceCount</span><span style=\"color:#F97583\">--</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (braceCount </span><span style=\"color:#F97583\">===</span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      argsEndIndex </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> i </span><span style=\"color:#F97583\">+</span><span style=\"color:#79B8FF\"> 1</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      foundClosingBrace </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      break</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>The proxy also validates tool calls against the list of available tools. If the model invents a tool that doesn't exist, it gets filtered out. If all tool calls are invalid, the response is treated as regular text.</p>\n<h2>Tool Call Streaming</h2>\n<p>OpenCode expects tool calls to arrive as SSE chunks, same as regular text. <code>streamToolCalls()</code> in <code>streaming.js</code> sends an init chunk with the tool name and ID, then an args chunk with the arguments, then a final chunk with <code>finish_reason: 'tool_calls'</code>. Each chunk has a small delay (20ms, 10ms, 20ms) to feel like actual streaming.</p>\n<h2>Conversation Summarization</h2>\n<p>This one sneaked up on me. Straico has model context limits. Some models have 8k tokens, some have 128k. OpenCode sends the entire conversation history with every request. In a long coding session, that history grows fast.</p>\n<p><code>summarizer.js</code> checks if the estimated token count is approaching the model's limit. When it hits a configurable threshold (default 70% of the model's <code>word_limit</code>), it takes all but the most recent messages, sends them to Straico for summarization, and replaces them with a single summary message.</p>\n<p>The summarization itself uses Straico's <code>smart_llm_selector</code> with <code>pricing_method: balance</code>, so it picks a cheap model for the summary. Configurable via <code>SUMMARIZATION_MODEL</code>.</p>\n<p>I'm still not 100% sure this is the right approach. The summary is lossy. Sometimes the model needs context from earlier messages that the summary glossed over. But without it, long sessions just fail with context limit errors. Tradeoff.</p>\n<h2>Model Limits and Validation</h2>\n<p><code>utils/model-limits.js</code> fetches all available models from Straico's <code>/models</code> endpoint at startup. It caches their context limits (<code>word_limit</code>) and max output tokens (<code>max_output</code>). The proxy uses this to validate incoming requests. If <code>estimated_input_tokens + max_tokens > word_limit</code>, it rejects the request with a 400 error before even hitting Straico.</p>\n<p>The model list is also exposed at <code>/v1/models</code> so OpenCode can discover what's available. There's an admin endpoint at <code>/v1/admin/refresh-models</code> to force a refresh if Straico adds new models.</p>\n<p>The sync script (<code>scripts/sync-opencode-config.js</code>) goes one step further. It fetches the model list from Straico, then updates <code>~/.config/opencode/opencode.json</code> with all chat-type models. The Docker entrypoint runs this script before starting the server, so the model list is always current.</p>\n<h2>Authentication</h2>\n<p>Four modes, controlled by <code>AUTH_MODE</code>:</p>\n<ul>\n<li><code>required</code> - Needs <code>PROXY_API_KEY</code>, rejects requests without it. Default in production.</li>\n<li><code>optional</code> - Uses the key if set, warns if not. Default in development.</li>\n<li><code>disabled</code> - No auth. For isolated environments.</li>\n<li><code>external</code> - Trusts an external auth header. For when the proxy sits behind an API gateway or service mesh.</li>\n</ul>\n<p>The key comparison uses <code>crypto.timingSafeEqual</code> to prevent timing attacks. Took me a moment to realise I needed buffer length checks too, since <code>timingSafeEqual</code> throws if the buffers are different lengths.</p>\n<h2>Retry and Graceful Shutdown</h2>\n<p><code>BaseProvider.makeRequestWithRetry()</code> wraps every API call with exponential backoff. Retries on 429, 5xx, and network errors (<code>ECONNREFUSED</code>, <code>ECONNRESET</code>, <code>ETIMEDOUT</code>). Default is 3 attempts with a 1-second base delay.</p>\n<p>Graceful shutdown was one of those things I didn't think about until I ran into issues. When Docker sends SIGTERM, the proxy stops accepting new requests and waits for active ones to drain. There's a timeout (default 30 seconds) after which it force-exits. Without this, long-running streaming responses would get cut off mid-chunk when the container restarted.</p>\n<h2>Docker Setup</h2>\n<p>The Dockerfile uses <code>node:18-alpine</code> and an entrypoint script. The entrypoint runs the OpenCode config sync, then starts the server.</p>\n<p>Docker Compose mounts two volumes. The <code>.env</code> file for config. And <code>~/.config/opencode</code> so the sync script can write to the OpenCode config file.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  - </span><span style=\"color:#9ECBFF\">./.env:/app/.env:ro</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  - </span><span style=\"color:#9ECBFF\">~/.config/opencode:/root/.config/opencode</span></span></code></pre>\n<p>One thing I got wrong initially was the Dockerfile <code>CMD</code>. I had <code>CMD [\"node\", \"server.js\"]</code> which meant the config sync never ran. Switched to <code>ENTRYPOINT [\"/app/docker-entrypoint.sh\"]</code> and that fixed it. Small thing, but it meant every container restart would have stale model lists.</p>\n<h2>The Straico-Specific Quirks</h2>\n<p>Straico's API is mostly OpenAI-compatible but with some differences that caught me out.</p>\n<p>Tool result messages use <code>role: \"tool\"</code> in OpenAI format. Straico doesn't support that role. The proxy converts them to <code>role: \"user\"</code> with a <code>[Tool Result]:</code> prefix. Same with assistant messages that contain tool calls. Those get converted to the text format the injection prompt expects.</p>\n<p>Empty assistant messages get filtered out entirely. Some models return an assistant message with empty content before making a tool call. Straico chokes on those.</p>\n<p>There's a <code>TOOL_RESULT_MAX_LENGTH</code> env var that truncates large tool outputs. Some tool results (file reads, command output) can be massive. Without truncation, they blow out the context window and the next request fails.</p>\n<p>The proxy also normalises messages. OpenAI sends content as arrays of objects (text parts, image parts, system reminders). The proxy flattens those into plain strings and strips out <code>&#x3C;system-reminder></code> tags. Straico doesn't know what to do with the array format.</p>\n<h2>What I'd Do Differently</h2>\n<p>The provider pattern is solid but I'd start with it from the beginning rather than refactoring into it. The four-file structure worked fine until I wanted to add features that crossed module boundaries. The abstraction would have saved me some reshuffling.</p>\n<p>The smart streaming mode is neat but I'd think harder about whether it's worth the complexity. The boundary detection handles most markdown but not all edge cases. <code>none</code> mode is faster and more reliable. I use <code>none</code> day to day.</p>\n<p>The summarization feature is the part I'm least confident about. It works, but the lossy compression means sometimes context gets dropped at exactly the wrong moment. I might revisit this with a sliding window approach instead of a hard summarize-and-replace.</p>\n<h2>Where It Stands</h2>\n<p>The proxy handles:</p>\n<ul>\n<li>All 90+ Straico models through a single endpoint</li>\n<li>Streaming simulation (both modes)</li>\n<li>Function calling with four parser strategies</li>\n<li>Conversation summarization for long sessions</li>\n<li>Model context validation</li>\n<li>Authentication with four modes</li>\n<li>Retry with exponential backoff</li>\n<li>Graceful shutdown with request draining</li>\n<li>Docker deployment with automatic model sync</li>\n</ul>\n<p>It runs on my machine and OpenCode talks to it at <code>http://localhost:8000/v1</code>. Works well enough that I don't think about it most of the time. Which is exactly what a proxy should do.</p>\n<p>The code is on GitHub if you want to look or use it. Or add a provider. The architecture supports it.</p>",
            "url": "https://lukemanning.ie/blog/building-straico-api-proxy",
            "title": "Straico Has Great Models But No Streaming, So I Built a Proxy",
            "summary": "<p>I use <a href=\"/blog/opencode-nested-slash-commands-architecture\">OpenCode</a> as my main AI coding tool. I switched from Claude Code after <a href=\"/blog/anthropic-open-source-walled-garden-clawdbot-opencode\">Anthropic started going after open source projects</a> and I kept hitting session limits on my subscription.</p>\n<p>OpenCode works with any OpenAI-compatible API. Straico gives me access to Claude, GPT, Gemini, DeepSeek, and a bunch more through a single API key. Cheap too. Problem is, Straico's API is missing two things OpenCode needs: streaming responses and function calling.</p>\n<p>Without streaming, OpenCode just hangs. Never gets a response. But Straico keeps eating tokens on their end anyway. Without function calling, the AI can't use tools like reading files or running bash commands. Both are non-negotiable for an agentic coding tool.</p>\n<p>So I built a proxy. It sits between OpenCode and Straico, translating requests and responses to fill in the gaps.</p>\n<pre><code>OpenCode\n  → localhost:8000 (my proxy)\n    → Straico API\n</code></pre>\n<p>What started as \"just simulate streaming and inject tool definitions\" turned into a surprisingly full-featured thing. The codebase is at <a href=\"https://github.com/ManningWorks/DOAI-Proxy\">github.com/ManningWorks/DOAI-Proxy</a>.</p>\n<h2>The Architecture I Ended Up With</h2>\n<p>I didn't start with a provider pattern. I started with four files: <code>server.js</code>, <code>streaming.js</code>, <code>tools.js</code>, <code>utils.js</code>. But once I started thinking about adding other providers down the line (OpenAI direct, Anthropic direct), I refactored into something cleaner.</p>\n<p>The provider pattern lives in <code>providers/</code>. <code>BaseProvider</code> is an abstract class that handles the interface contract and retry logic. <code>StraicoProvider</code> extends it with Straico-specific request/response transformation. <code>ProviderFactory</code> instantiates the right one based on the <code>PROVIDER_TYPE</code> env var.</p>\n<p>Right now only Straico exists, but the factory already has stubs for OpenAI and Anthropic. The <code>ADDING_PROVIDERS.md</code> doc in the repo lays out how to add a new one.</p>\n<p>The other modules:</p>\n<ul>\n<li><code>server.js</code> - Express server, routing, auth, request lifecycle</li>\n<li><code>streaming.js</code> - SSE simulation with two modes</li>\n<li><code>tools.js</code> - Tool injection and response parsing</li>\n<li><code>utils.js</code> - Logging, formatting, log rotation</li>\n<li><code>utils/model-limits.js</code> - Fetches context limits from Straico's API</li>\n<li><code>summarizer.js</code> - Conversation summarization for long sessions</li>\n<li><code>scripts/sync-opencode-config.js</code> - Syncs model list to OpenCode config</li>\n</ul>\n<h2>Streaming Without Streaming</h2>\n<p>Straico returns the full response at once. No SSE. No chunks. The proxy has to fake it.</p>\n<p>Two modes: <code>none</code> and <code>smart</code>.</p>\n<p><code>none</code> is what I'd recommend as default. It sends the entire response in one SSE chunk, then the <code>[DONE]</code> marker. Fast, no formatting issues, still technically SSE.</p>\n<p><code>smart</code> is more interesting. It splits the response into chunks with delays to simulate real streaming. The naive approach is <code>responseText.match(new RegExp('.{1,15}', 'g'))</code> and that kind of works. But it breaks markdown. Split mid-bold, mid-code-block, mid-backtick and the rendering glitches.</p>\n<p>So <code>smartChunkText()</code> in <code>streaming.js</code> looks for safe boundaries. It prefers splitting on newlines, then whitespace. It also checks for markdown delimiters (<code>**</code>, <code>__</code>, <code>\\</code>``<code>, `` </code> ``) and extends the chunk to avoid splitting them. There's a max size limit (<code>targetSize * 10</code>) to prevent infinite extension.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// streaming.js - simplified version of the boundary logic</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">let</span><span style=\"color:#E1E4E8\"> i </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> end; i </span><span style=\"color:#F97583\">></span><span style=\"color:#E1E4E8\"> start; i</span><span style=\"color:#F97583\">--</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (text[i] </span><span style=\"color:#F97583\">===</span><span style=\"color:#9ECBFF\"> '</span><span style=\"color:#79B8FF\">\\n</span><span style=\"color:#9ECBFF\">'</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> i </span><span style=\"color:#F97583\">+</span><span style=\"color:#79B8FF\"> 1</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> delim</span><span style=\"color:#F97583\"> of</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#9ECBFF\">'**'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'__'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'```'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'`'</span><span style=\"color:#E1E4E8\">]) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> delimStart</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> text.</span><span style=\"color:#B392F0\">indexOf</span><span style=\"color:#E1E4E8\">(delim, start);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (delimStart </span><span style=\"color:#F97583\">!==</span><span style=\"color:#F97583\"> -</span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> delimStart </span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#E1E4E8\"> end) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#79B8FF\"> delimEnd</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> delimStart </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> delim.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (delimEnd </span><span style=\"color:#F97583\">></span><span style=\"color:#E1E4E8\"> end) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      return</span><span style=\"color:#E1E4E8\"> Math.</span><span style=\"color:#B392F0\">min</span><span style=\"color:#E1E4E8\">(delimEnd, text.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">, start </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> maxSize);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Default is 15 characters per chunk with 80ms delay. That feels about right for most models. Configurable via <code>STREAM_CHUNK_SIZE</code> and <code>STREAM_DELAY_MS</code> env vars.</p>\n<p>I set <code>STREAM_MODE=none</code> as the recommended default. <code>smart</code> works but it's more of a showcase thing. The boundary detection catches most cases but I wouldn't trust it with complex nested markdown.</p>\n<h2>Function Calling via Prompt Injection</h2>\n<p>Straico doesn't support function calling natively. The workaround: inject tool definitions into the system prompt and parse the AI's response to detect tool calls.</p>\n<p><code>injectToolsIntoSystem()</code> in <code>tools.js</code> appends a formatted list of available tools to the system message:</p>\n<pre><code>You have access to the following tools:\n- bash: Run bash commands\n- read: Read file contents\n\nWhen you need to use a tool, format your response like this:\nTOOL_CALL: &#x3C;tool_name>\nARGUMENTS: &#x3C;json_arguments>\n</code></pre>\n<p>There's a sentinel comment (<code>&#x3C;!-- proxy-tools-injected --></code>) to prevent double-injection if the same messages get processed twice.</p>\n<p>The tricky part is parsing. Different models output tool calls in different formats. I ended up with four parsers that run in sequence:</p>\n<ol>\n<li><strong>Minimax XML</strong> - <code>&#x3C;minimax:tool_call></code> with <code>&#x3C;invoke></code> tags</li>\n<li><strong>Claude XML</strong> - <code>&#x3C;invoke name=\"...\"></code> with <code>&#x3C;parameter_list></code> tags</li>\n<li><strong>OpenAI Native</strong> - JSON with <code>\"tool_calls\": [...]</code> embedded in the response</li>\n<li><strong>Text Format</strong> - The <code>TOOL_CALL: / ARGUMENTS:</code> format from the injection prompt</li>\n</ol>\n<p>Each parser tries to extract tool calls from the response text. The first one that succeeds wins. This was a gradual thing. I started with just the text format parser. Then Minimax models returned XML. Then Claude models returned different XML. Then some models returned JSON that looked like OpenAI's format. Four parsers later and it handles most cases.</p>\n<p>The text format parser was the hardest to get right. Matching <code>TOOL_CALL: tool_name ARGUMENTS: {json}</code> seems simple until the JSON contains nested objects, strings with braces, or the model forgets the space between the tool name and ARGUMENTS. The implementation tracks brace depth to find where the JSON actually ends:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">let</span><span style=\"color:#E1E4E8\"> i </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> argsStartIndex; i </span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#E1E4E8\"> responseText.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">; i</span><span style=\"color:#F97583\">++</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> char</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> responseText[i];</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (char </span><span style=\"color:#F97583\">===</span><span style=\"color:#9ECBFF\"> '{'</span><span style=\"color:#E1E4E8\">) braceCount</span><span style=\"color:#F97583\">++</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  else</span><span style=\"color:#F97583\"> if</span><span style=\"color:#E1E4E8\"> (char </span><span style=\"color:#F97583\">===</span><span style=\"color:#9ECBFF\"> '}'</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    braceCount</span><span style=\"color:#F97583\">--</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (braceCount </span><span style=\"color:#F97583\">===</span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      argsEndIndex </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> i </span><span style=\"color:#F97583\">+</span><span style=\"color:#79B8FF\"> 1</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      foundClosingBrace </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      break</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>The proxy also validates tool calls against the list of available tools. If the model invents a tool that doesn't exist, it gets filtered out. If all tool calls are invalid, the response is treated as regular text.</p>\n<h2>Tool Call Streaming</h2>\n<p>OpenCode expects tool calls to arrive as SSE chunks, same as regular text. <code>streamToolCalls()</code> in <code>streaming.js</code> sends an init chunk with the tool name and ID, then an args chunk with the arguments, then a final chunk with <code>finish_reason: 'tool_calls'</code>. Each chunk has a small delay (20ms, 10ms, 20ms) to feel like actual streaming.</p>\n<h2>Conversation Summarization</h2>\n<p>This one sneaked up on me. Straico has model context limits. Some models have 8k tokens, some have 128k. OpenCode sends the entire conversation history with every request. In a long coding session, that history grows fast.</p>\n<p><code>summarizer.js</code> checks if the estimated token count is approaching the model's limit. When it hits a configurable threshold (default 70% of the model's <code>word_limit</code>), it takes all but the most recent messages, sends them to Straico for summarization, and replaces them with a single summary message.</p>\n<p>The summarization itself uses Straico's <code>smart_llm_selector</code> with <code>pricing_method: balance</code>, so it picks a cheap model for the summary. Configurable via <code>SUMMARIZATION_MODEL</code>.</p>\n<p>I'm still not 100% sure this is the right approach. The summary is lossy. Sometimes the model needs context from earlier messages that the summary glossed over. But without it, long sessions just fail with context limit errors. Tradeoff.</p>\n<h2>Model Limits and Validation</h2>\n<p><code>utils/model-limits.js</code> fetches all available models from Straico's <code>/models</code> endpoint at startup. It caches their context limits (<code>word_limit</code>) and max output tokens (<code>max_output</code>). The proxy uses this to validate incoming requests. If <code>estimated_input_tokens + max_tokens > word_limit</code>, it rejects the request with a 400 error before even hitting Straico.</p>\n<p>The model list is also exposed at <code>/v1/models</code> so OpenCode can discover what's available. There's an admin endpoint at <code>/v1/admin/refresh-models</code> to force a refresh if Straico adds new models.</p>\n<p>The sync script (<code>scripts/sync-opencode-config.js</code>) goes one step further. It fetches the model list from Straico, then updates <code>~/.config/opencode/opencode.json</code> with all chat-type models. The Docker entrypoint runs this script before starting the server, so the model list is always current.</p>\n<h2>Authentication</h2>\n<p>Four modes, controlled by <code>AUTH_MODE</code>:</p>\n<ul>\n<li><code>required</code> - Needs <code>PROXY_API_KEY</code>, rejects requests without it. Default in production.</li>\n<li><code>optional</code> - Uses the key if set, warns if not. Default in development.</li>\n<li><code>disabled</code> - No auth. For isolated environments.</li>\n<li><code>external</code> - Trusts an external auth header. For when the proxy sits behind an API gateway or service mesh.</li>\n</ul>\n<p>The key comparison uses <code>crypto.timingSafeEqual</code> to prevent timing attacks. Took me a moment to realise I needed buffer length checks too, since <code>timingSafeEqual</code> throws if the buffers are different lengths.</p>\n<h2>Retry and Graceful Shutdown</h2>\n<p><code>BaseProvider.makeRequestWithRetry()</code> wraps every API call with exponential backoff. Retries on 429, 5xx, and network errors (<code>ECONNREFUSED</code>, <code>ECONNRESET</code>, <code>ETIMEDOUT</code>). Default is 3 attempts with a 1-second base delay.</p>\n<p>Graceful shutdown was one of those things I didn't think about until I ran into issues. When Docker sends SIGTERM, the proxy stops accepting new requests and waits for active ones to drain. There's a timeout (default 30 seconds) after which it force-exits. Without this, long-running streaming responses would get cut off mid-chunk when the container restarted.</p>\n<h2>Docker Setup</h2>\n<p>The Dockerfile uses <code>node:18-alpine</code> and an entrypoint script. The entrypoint runs the OpenCode config sync, then starts the server.</p>\n<p>Docker Compose mounts two volumes. The <code>.env</code> file for config. And <code>~/.config/opencode</code> so the sync script can write to the OpenCode config file.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  - </span><span style=\"color:#9ECBFF\">./.env:/app/.env:ro</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  - </span><span style=\"color:#9ECBFF\">~/.config/opencode:/root/.config/opencode</span></span></code></pre>\n<p>One thing I got wrong initially was the Dockerfile <code>CMD</code>. I had <code>CMD [\"node\", \"server.js\"]</code> which meant the config sync never ran. Switched to <code>ENTRYPOINT [\"/app/docker-entrypoint.sh\"]</code> and that fixed it. Small thing, but it meant every container restart would have stale model lists.</p>\n<h2>The Straico-Specific Quirks</h2>\n<p>Straico's API is mostly OpenAI-compatible but with some differences that caught me out.</p>\n<p>Tool result messages use <code>role: \"tool\"</code> in OpenAI format. Straico doesn't support that role. The proxy converts them to <code>role: \"user\"</code> with a <code>[Tool Result]:</code> prefix. Same with assistant messages that contain tool calls. Those get converted to the text format the injection prompt expects.</p>\n<p>Empty assistant messages get filtered out entirely. Some models return an assistant message with empty content before making a tool call. Straico chokes on those.</p>\n<p>There's a <code>TOOL_RESULT_MAX_LENGTH</code> env var that truncates large tool outputs. Some tool results (file reads, command output) can be massive. Without truncation, they blow out the context window and the next request fails.</p>\n<p>The proxy also normalises messages. OpenAI sends content as arrays of objects (text parts, image parts, system reminders). The proxy flattens those into plain strings and strips out <code>&#x3C;system-reminder></code> tags. Straico doesn't know what to do with the array format.</p>\n<h2>What I'd Do Differently</h2>\n<p>The provider pattern is solid but I'd start with it from the beginning rather than refactoring into it. The four-file structure worked fine until I wanted to add features that crossed module boundaries. The abstraction would have saved me some reshuffling.</p>\n<p>The smart streaming mode is neat but I'd think harder about whether it's worth the complexity. The boundary detection handles most markdown but not all edge cases. <code>none</code> mode is faster and more reliable. I use <code>none</code> day to day.</p>\n<p>The summarization feature is the part I'm least confident about. It works, but the lossy compression means sometimes context gets dropped at exactly the wrong moment. I might revisit this with a sliding window approach instead of a hard summarize-and-replace.</p>\n<h2>Where It Stands</h2>\n<p>The proxy handles:</p>\n<ul>\n<li>All 90+ Straico models through a single endpoint</li>\n<li>Streaming simulation (both modes)</li>\n<li>Function calling with four parser strategies</li>\n<li>Conversation summarization for long sessions</li>\n<li>Model context validation</li>\n<li>Authentication with four modes</li>\n<li>Retry with exponential backoff</li>\n<li>Graceful shutdown with request draining</li>\n<li>Docker deployment with automatic model sync</li>\n</ul>\n<p>It runs on my machine and OpenCode talks to it at <code>http://localhost:8000/v1</code>. Works well enough that I don't think about it most of the time. Which is exactly what a proxy should do.</p>\n<p>The code is on GitHub if you want to look or use it. Or add a provider. The architecture supports it.</p>",
            "date_modified": "2026-04-13T00:00:00.000Z",
            "tags": [
                "opencode"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/npm-package-json-vs-package-lock-json",
            "content_html": "<p>I updated <code>@manningworks/projex</code> to 1.2.0. At least, I thought I did. The installed version was 1.2.0, confirmed with <code>npm ls</code>. But my <code>package.json</code> still said <code>^1.1.3</code>.</p>\n<p>Wait, what?</p>\n<p>I assumed <code>npm update</code> would update <code>package.json</code>. That seems like the obvious thing it should do. It doesn't. And that's by design. But figuring out <em>why</em> took me through a bunch of npm documentation I'd never actually read properly.</p>\n<hr>\n<h2>What I Thought Happened</h2>\n<p>Here's what I assumed the workflow was:</p>\n<ol>\n<li>Run <code>npm update</code></li>\n<li>New versions get installed</li>\n<li><code>package.json</code> gets updated to reflect the new versions</li>\n<li><code>package-lock.json</code> gets updated too</li>\n</ol>\n<p>Steps 2 and 4 happened. Step 3 did not.</p>\n<p>I stared at my <code>package.json</code> for a while wondering if I'd hallucinated the update. I hadn't. The package was definitely on 1.2.0 in <code>node_modules</code>. But the file that's supposed to declare my dependencies was lying to me.</p>\n<p>Or so I thought.</p>\n<hr>\n<h2>What package.json Actually Does</h2>\n<p>I hit the npm docs. Started with the <code>package-lock.json</code> docs, then worked backward to how <code>package.json</code> handles versions. The first thing that clicked was realizing I'd been thinking about <code>package.json</code> wrong the whole time.</p>\n<p><code>package.json</code> doesn't store the exact version you have installed. It stores a <strong>semver range</strong>. The <code>^</code> prefix means \"compatible with this version.\" <code>^1.1.3</code> means \"anything from 1.1.3 up to, but not including, 2.0.0.\" (There's also <code>~</code> for patch-only updates, and no prefix pins the exact version. But <code>^</code> is what npm uses by default, which is why nearly every entry in your <code>package.json</code> has it.)</p>\n<p>So when I had <code>\"@manningworks/projex\": \"^1.1.3\"</code> and npm installed 1.2.0, that's working as intended. 1.2.0 satisfies the range <code>>=1.1.3 &#x3C;2.0.0</code>. The range didn't need to change.</p>\n<p>I didn't know this. I thought the version in <code>package.json</code> was <em>the version</em>, not <em>the minimum acceptable version</em>.</p>\n<hr>\n<h2>What package-lock.json Does</h2>\n<p>This is the part I was fuzzy on. <code>package-lock.json</code> stores the <strong>exact</strong> version installed for every package in your dependency tree. Not ranges. Exact versions.</p>\n<p>When you run <code>npm install</code>, npm looks at the ranges in <code>package.json</code>, resolves them to specific versions, installs those, and writes the exact resolutions to <code>package-lock.json</code>.</p>\n<p>Next time someone clones your repo and runs <code>npm install</code>, they get the exact same versions. Not \"whatever the latest is that satisfies the range.\" The same versions. That's the point.</p>\n<p>From the <a href=\"https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json\">npm docs on package-lock.json</a>:</p>\n<blockquote>\n<p>This file is intended to be committed into source repositories, and serves various purposes: describe a single representation of a dependency tree such that teammates, deployments, and continuous integration are guaranteed to install exactly the same dependencies.</p>\n</blockquote>\n<p>So the two files work together:</p>\n<ul>\n<li><strong><code>package.json</code></strong> says \"I want something in this range\"</li>\n<li><strong><code>package-lock.json</code></strong> says \"here's the specific version I chose and committed to\"</li>\n</ul>\n<hr>\n<h2>Why npm update Doesn't Touch package.json</h2>\n<p>This is the part that felt wrong to me. (I'm on npm 11, for reference — the behavior may differ on older versions.) The <a href=\"https://docs.npmjs.com/cli/v11/commands/npm-update\">official npm docs for <code>npm update</code></a> say:</p>\n<blockquote>\n<p>Note that by default npm update will not update the semver values of direct dependencies in your project package.json.</p>\n</blockquote>\n<p>Why? Because the semver range in <code>package.json</code> is a constraint, not a version. If <code>^1.1.3</code> already allows 1.2.0, there's no reason to change it to <code>^1.2.0</code>. The range still works. The lockfile already reflects the actual installed version.</p>\n<p>There's a configuration option called <code>save</code> that controls this. From the docs:</p>\n<blockquote>\n<p><strong>save</strong> — Default: <code>true</code> unless when using <code>npm update</code> where it defaults to <code>false</code></p>\n</blockquote>\n<p>So <code>npm update</code> explicitly opts out of writing to <code>package.json</code>. You can override it with <code>npm update --save</code>.</p>\n<hr>\n<h2>What Happens When You Use --save</h2>\n<p>I ran <code>npm update --save</code> to force <code>package.json</code> to update. And it did. But the result looked... wrong.</p>\n<p>Before:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#9ECBFF\">\"@types/node\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^20\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">\"eslint\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^9\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">\"typescript\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^5\"</span></span></code></pre>\n<p>After:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#9ECBFF\">\"@types/node\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^20.19.39\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">\"eslint\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^9.39.4\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">\"typescript\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^5.9.3\"</span></span></code></pre>\n<p>Very specific. Down to major.minor.patch. That felt bad. Was I now pinned to exact versions?</p>\n<p>No. The <code>^</code> is still there. <code>^5.9.3</code> still means <code>>=5.9.3 &#x3C;6.0.0</code>. The range just starts from a higher floor.</p>\n<h3>Is This Good or Bad?</h3>\n<p>I'm still not totally sure, but I've settled on broad ranges being fine.</p>\n<p>The lockfile is the source of truth for what's installed. <code>package.json</code> is the source of truth for what range you're willing to accept. If I pin <code>^5.9.3</code> in <code>package.json</code>, I'm just duplicating what the lockfile already tracks. The only scenario where the pinned version matters is if someone deletes the lockfile — and I'd rather solve that with \"don't delete the lockfile\" than by making <code>package.json</code> more restrictive.</p>\n<hr>\n<h2>What I'm Doing Going Forward</h2>\n<p>I'm going back to broad ranges in <code>package.json</code>. I'll use <code>npm update</code> without <code>--save</code> to keep the lockfile current, and let <code>package.json</code> stay as the loose constraint it's designed to be.</p>\n<p>If I need to pin a specific version, I'll do it intentionally with <code>npm install package@exact-version</code>, not as a side effect of <code>npm update --save</code>.</p>\n<p>The key thing I was missing: <code>package.json</code> and <code>package-lock.json</code> aren't saying the same thing in different ways. They're saying different things on purpose.</p>\n<p>I wish someone had explained that to me before I spent a while being confused by a file that was working exactly as designed.</p>",
            "url": "https://lukemanning.ie/blog/npm-package-json-vs-package-lock-json",
            "title": "I Updated a Package and package.json Didn't Change. Turns Out That's Normal.",
            "summary": "<p>I updated <code>@manningworks/projex</code> to 1.2.0. At least, I thought I did. The installed version was 1.2.0, confirmed with <code>npm ls</code>. But my <code>package.json</code> still said <code>^1.1.3</code>.</p>\n<p>Wait, what?</p>\n<p>I assumed <code>npm update</code> would update <code>package.json</code>. That seems like the obvious thing it should do. It doesn't. And that's by design. But figuring out <em>why</em> took me through a bunch of npm documentation I'd never actually read properly.</p>\n<hr>\n<h2>What I Thought Happened</h2>\n<p>Here's what I assumed the workflow was:</p>\n<ol>\n<li>Run <code>npm update</code></li>\n<li>New versions get installed</li>\n<li><code>package.json</code> gets updated to reflect the new versions</li>\n<li><code>package-lock.json</code> gets updated too</li>\n</ol>\n<p>Steps 2 and 4 happened. Step 3 did not.</p>\n<p>I stared at my <code>package.json</code> for a while wondering if I'd hallucinated the update. I hadn't. The package was definitely on 1.2.0 in <code>node_modules</code>. But the file that's supposed to declare my dependencies was lying to me.</p>\n<p>Or so I thought.</p>\n<hr>\n<h2>What package.json Actually Does</h2>\n<p>I hit the npm docs. Started with the <code>package-lock.json</code> docs, then worked backward to how <code>package.json</code> handles versions. The first thing that clicked was realizing I'd been thinking about <code>package.json</code> wrong the whole time.</p>\n<p><code>package.json</code> doesn't store the exact version you have installed. It stores a <strong>semver range</strong>. The <code>^</code> prefix means \"compatible with this version.\" <code>^1.1.3</code> means \"anything from 1.1.3 up to, but not including, 2.0.0.\" (There's also <code>~</code> for patch-only updates, and no prefix pins the exact version. But <code>^</code> is what npm uses by default, which is why nearly every entry in your <code>package.json</code> has it.)</p>\n<p>So when I had <code>\"@manningworks/projex\": \"^1.1.3\"</code> and npm installed 1.2.0, that's working as intended. 1.2.0 satisfies the range <code>>=1.1.3 &#x3C;2.0.0</code>. The range didn't need to change.</p>\n<p>I didn't know this. I thought the version in <code>package.json</code> was <em>the version</em>, not <em>the minimum acceptable version</em>.</p>\n<hr>\n<h2>What package-lock.json Does</h2>\n<p>This is the part I was fuzzy on. <code>package-lock.json</code> stores the <strong>exact</strong> version installed for every package in your dependency tree. Not ranges. Exact versions.</p>\n<p>When you run <code>npm install</code>, npm looks at the ranges in <code>package.json</code>, resolves them to specific versions, installs those, and writes the exact resolutions to <code>package-lock.json</code>.</p>\n<p>Next time someone clones your repo and runs <code>npm install</code>, they get the exact same versions. Not \"whatever the latest is that satisfies the range.\" The same versions. That's the point.</p>\n<p>From the <a href=\"https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json\">npm docs on package-lock.json</a>:</p>\n<blockquote>\n<p>This file is intended to be committed into source repositories, and serves various purposes: describe a single representation of a dependency tree such that teammates, deployments, and continuous integration are guaranteed to install exactly the same dependencies.</p>\n</blockquote>\n<p>So the two files work together:</p>\n<ul>\n<li><strong><code>package.json</code></strong> says \"I want something in this range\"</li>\n<li><strong><code>package-lock.json</code></strong> says \"here's the specific version I chose and committed to\"</li>\n</ul>\n<hr>\n<h2>Why npm update Doesn't Touch package.json</h2>\n<p>This is the part that felt wrong to me. (I'm on npm 11, for reference — the behavior may differ on older versions.) The <a href=\"https://docs.npmjs.com/cli/v11/commands/npm-update\">official npm docs for <code>npm update</code></a> say:</p>\n<blockquote>\n<p>Note that by default npm update will not update the semver values of direct dependencies in your project package.json.</p>\n</blockquote>\n<p>Why? Because the semver range in <code>package.json</code> is a constraint, not a version. If <code>^1.1.3</code> already allows 1.2.0, there's no reason to change it to <code>^1.2.0</code>. The range still works. The lockfile already reflects the actual installed version.</p>\n<p>There's a configuration option called <code>save</code> that controls this. From the docs:</p>\n<blockquote>\n<p><strong>save</strong> — Default: <code>true</code> unless when using <code>npm update</code> where it defaults to <code>false</code></p>\n</blockquote>\n<p>So <code>npm update</code> explicitly opts out of writing to <code>package.json</code>. You can override it with <code>npm update --save</code>.</p>\n<hr>\n<h2>What Happens When You Use --save</h2>\n<p>I ran <code>npm update --save</code> to force <code>package.json</code> to update. And it did. But the result looked... wrong.</p>\n<p>Before:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#9ECBFF\">\"@types/node\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^20\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">\"eslint\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^9\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">\"typescript\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^5\"</span></span></code></pre>\n<p>After:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#9ECBFF\">\"@types/node\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^20.19.39\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">\"eslint\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^9.39.4\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">\"typescript\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"^5.9.3\"</span></span></code></pre>\n<p>Very specific. Down to major.minor.patch. That felt bad. Was I now pinned to exact versions?</p>\n<p>No. The <code>^</code> is still there. <code>^5.9.3</code> still means <code>>=5.9.3 &#x3C;6.0.0</code>. The range just starts from a higher floor.</p>\n<h3>Is This Good or Bad?</h3>\n<p>I'm still not totally sure, but I've settled on broad ranges being fine.</p>\n<p>The lockfile is the source of truth for what's installed. <code>package.json</code> is the source of truth for what range you're willing to accept. If I pin <code>^5.9.3</code> in <code>package.json</code>, I'm just duplicating what the lockfile already tracks. The only scenario where the pinned version matters is if someone deletes the lockfile — and I'd rather solve that with \"don't delete the lockfile\" than by making <code>package.json</code> more restrictive.</p>\n<hr>\n<h2>What I'm Doing Going Forward</h2>\n<p>I'm going back to broad ranges in <code>package.json</code>. I'll use <code>npm update</code> without <code>--save</code> to keep the lockfile current, and let <code>package.json</code> stay as the loose constraint it's designed to be.</p>\n<p>If I need to pin a specific version, I'll do it intentionally with <code>npm install package@exact-version</code>, not as a side effect of <code>npm update --save</code>.</p>\n<p>The key thing I was missing: <code>package.json</code> and <code>package-lock.json</code> aren't saying the same thing in different ways. They're saying different things on purpose.</p>\n<p>I wish someone had explained that to me before I spent a while being confused by a file that was working exactly as designed.</p>",
            "date_modified": "2026-04-12T00:00:00.000Z",
            "tags": [
                "nextjs"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/opencode-subagent-permissions-ordering-trap",
            "content_html": "<p>Every time my release-manager subagent tried to run <code>grep</code> or <code>git log</code>, OpenCode prompted me for approval. I had explicit <code>\"grep *\": allow</code> rules in the config. They weren't working.</p>\n<p>This tripped me up for way longer than it should have. (I'm running OpenCode v1.4 — permission behavior may differ in other versions.)</p>\n<h2>My Config</h2>\n<p>The <a href=\"https://github.com/ManningWorks/Projex/blob/main/.opencode/agents/release-manager.md\">release-manager agent</a> handles version bumps, changelogs, and git tagging for my Projex project. Its config in <code>.opencode/agents/release-manager.md</code> had bash permissions like this:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">bash</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"grep *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"rg *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"cat *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git log -- *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git diff -- *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git status\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ask</span></span></code></pre>\n<p>Looks reasonable. Specific commands are allowed, everything else asks. Except every single command was prompting. <code>grep</code>, <code>git log</code>, <code>cat</code> — all asking for permission.</p>\n<h2>The Problem</h2>\n<p>I re-read the config about five times. The rules were right there — <code>\"grep *\": allow</code> — clear as day. I tried shuffling the order around. Tried different wildcard syntax. Nothing worked.</p>\n<p>Eventually I went back to the <a href=\"https://opencode.ai/docs/permissions/\">OpenCode permissions docs</a> and actually looked at the examples. Every single one puts the catch-all <code>\"*\": \"ask\"</code> at the top. Mine was at the bottom.</p>\n<p>Turns out OpenCode permission rules work on a \"last matching rule wins\" principle.</p>\n<p>The <code>\"*\"</code> wildcard matches everything. Including <code>grep</code>. Including <code>git log</code>. So when OpenCode evaluates <code>git log --oneline -20</code> against my config, both <code>\"git log -- *\"</code> and <code>\"*\"</code> match. Since <code>\"*\"</code> is listed last, it wins. Result: <code>ask</code>.</p>\n<p>Every. Single. Time.</p>\n<p>The fix was embarrassingly simple. Move the catch-all to the top:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">bash</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ask</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"grep *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"rg *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"cat *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git log -- *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git diff -- *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git status\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span></code></pre>\n<p>Now <code>\"*\"</code> matches first, but then <code>\"grep *\"</code> matches after and wins because it's the last matching rule. Specific overrides general. The way it should work.</p>\n<p>I'd put the catch-all at the bottom out of habit — like a default case in a switch statement. I just... didn't read carefully enough.</p>\n<h2>The Second Problem</h2>\n<p>After fixing the ordering, <code>grep</code> worked fine. But <code>git log --oneline -20</code> <em>still</em> prompted.</p>\n<p>I tested a few more variations to narrow it down. <code>git log -- somefile</code> (with the path separator) matched. <code>git log --oneline</code> didn't. The difference clicked pretty fast after that.</p>\n<p>The pattern <code>\"git log -- *\"</code> only matches commands that literally have <code>-- </code> in them. Like <code>git log -- somefile</code>. It doesn't match <code>git log --oneline -20</code> because <code>--oneline</code> is a flag, not the <code>--</code> path separator.</p>\n<p>I'd been too specific. Changed to:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#9ECBFF\">  \"git log *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git log\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git diff *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git diff\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git status *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git status\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span></code></pre>\n<p>Both forms — with and without arguments. The <code>*</code> wildcard matches zero or more of any character. So <code>\"git log *\"</code> covers <code>git log --oneline -20</code>. But the space before <code>*</code> is literal. The pattern requires \"git log\" followed by a space, then anything. A bare <code>git log</code> with no trailing space won't match it. That's why <code>\"git log\"</code> (no wildcard) is also needed.</p>\n<h2>The Third Problem</h2>\n<p>Even after both fixes, it <em>still</em> prompted. I'd been eyeing the <code>tools</code> field suspiciously since the start. The config had both the deprecated <code>tools</code> block and the newer <code>permission</code> block.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">tools</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  read</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  write</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  edit</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  bash</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">permission</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  bash</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ask</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"grep *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span></code></pre>\n<p>The docs say <code>tools.bash: true</code> is equivalent to <code>{\"*\": \"allow\"}</code>. Having both <code>tools</code> and <code>permission</code> for the same thing seemed like it could cause conflicts — one saying \"allow everything\" and the other saying \"ask for everything except these.\" I don't know exactly how OpenCode resolves this internally, but removing the <code>tools</code> block entirely fixed the remaining issues.</p>\n<h2>The Working Config</h2>\n<p>The final config for the release-manager agent looks like this:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">description</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Create releases following Projex's version bump, changelog, and git tagging workflow</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">mode</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">subagent</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">temperature</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">0.1</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">permission</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  edit</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ask</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"packages/core/package.json\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"CHANGELOG.md\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"README.md\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"packages/core/README.md\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"packages/docs/**/*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  bash</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ask</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"pnpm --filter * build\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"pnpm --filter * lint\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"pnpm --filter * typecheck\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"pnpm --filter * test\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"head *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"ls *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"ls -la *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"find *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"grep *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"rg *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"cat *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"tail *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"wc *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"wc -l *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"sort *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"uniq *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git log *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git log\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git diff *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git diff\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git status *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git status\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git tag *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  webfetch</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">deny</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  color</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">success</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">---</span></span></code></pre>\n<p>Three separate issues stacked on top of each other. Rule ordering, overly narrow patterns, and deprecated config conflicting with new config.</p>\n<h2>TL;DR</h2>\n<p>Three things I learned about OpenCode subagent permissions:</p>\n<ol>\n<li><strong>Catch-all goes first.</strong> <code>\"*\": ask</code> needs to be at the top of the permission block. Last matching rule wins.</li>\n<li><strong>Patterns need to match actual command syntax.</strong> <code>\"git log -- *\"</code> doesn't match <code>git log --oneline</code>. I had to use <code>\"git log *\"</code> instead.</li>\n<li><strong>Don't mix <code>tools</code> and <code>permission</code>.</strong> Removing the deprecated <code>tools</code> field fixed the remaining issues for me.</li>\n</ol>\n<p>I fixed the same issues in my <a href=\"https://github.com/ManningWorks/Projex/blob/main/.opencode/agents/documentation-manager.md\">documentation-manager agent</a> too. Both agents now run without prompting on every command. Which is how it should have been from the start.</p>",
            "url": "https://lukemanning.ie/blog/opencode-subagent-permissions-ordering-trap",
            "title": "My Subagents Kept Asking Permission for Everything: The Config Ordering Trap",
            "summary": "<p>Every time my release-manager subagent tried to run <code>grep</code> or <code>git log</code>, OpenCode prompted me for approval. I had explicit <code>\"grep *\": allow</code> rules in the config. They weren't working.</p>\n<p>This tripped me up for way longer than it should have. (I'm running OpenCode v1.4 — permission behavior may differ in other versions.)</p>\n<h2>My Config</h2>\n<p>The <a href=\"https://github.com/ManningWorks/Projex/blob/main/.opencode/agents/release-manager.md\">release-manager agent</a> handles version bumps, changelogs, and git tagging for my Projex project. Its config in <code>.opencode/agents/release-manager.md</code> had bash permissions like this:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">bash</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"grep *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"rg *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"cat *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git log -- *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git diff -- *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git status\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ask</span></span></code></pre>\n<p>Looks reasonable. Specific commands are allowed, everything else asks. Except every single command was prompting. <code>grep</code>, <code>git log</code>, <code>cat</code> — all asking for permission.</p>\n<h2>The Problem</h2>\n<p>I re-read the config about five times. The rules were right there — <code>\"grep *\": allow</code> — clear as day. I tried shuffling the order around. Tried different wildcard syntax. Nothing worked.</p>\n<p>Eventually I went back to the <a href=\"https://opencode.ai/docs/permissions/\">OpenCode permissions docs</a> and actually looked at the examples. Every single one puts the catch-all <code>\"*\": \"ask\"</code> at the top. Mine was at the bottom.</p>\n<p>Turns out OpenCode permission rules work on a \"last matching rule wins\" principle.</p>\n<p>The <code>\"*\"</code> wildcard matches everything. Including <code>grep</code>. Including <code>git log</code>. So when OpenCode evaluates <code>git log --oneline -20</code> against my config, both <code>\"git log -- *\"</code> and <code>\"*\"</code> match. Since <code>\"*\"</code> is listed last, it wins. Result: <code>ask</code>.</p>\n<p>Every. Single. Time.</p>\n<p>The fix was embarrassingly simple. Move the catch-all to the top:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">bash</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ask</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"grep *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"rg *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"cat *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git log -- *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git diff -- *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git status\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span></code></pre>\n<p>Now <code>\"*\"</code> matches first, but then <code>\"grep *\"</code> matches after and wins because it's the last matching rule. Specific overrides general. The way it should work.</p>\n<p>I'd put the catch-all at the bottom out of habit — like a default case in a switch statement. I just... didn't read carefully enough.</p>\n<h2>The Second Problem</h2>\n<p>After fixing the ordering, <code>grep</code> worked fine. But <code>git log --oneline -20</code> <em>still</em> prompted.</p>\n<p>I tested a few more variations to narrow it down. <code>git log -- somefile</code> (with the path separator) matched. <code>git log --oneline</code> didn't. The difference clicked pretty fast after that.</p>\n<p>The pattern <code>\"git log -- *\"</code> only matches commands that literally have <code>-- </code> in them. Like <code>git log -- somefile</code>. It doesn't match <code>git log --oneline -20</code> because <code>--oneline</code> is a flag, not the <code>--</code> path separator.</p>\n<p>I'd been too specific. Changed to:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#9ECBFF\">  \"git log *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git log\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git diff *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git diff\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git status *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"git status\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span></code></pre>\n<p>Both forms — with and without arguments. The <code>*</code> wildcard matches zero or more of any character. So <code>\"git log *\"</code> covers <code>git log --oneline -20</code>. But the space before <code>*</code> is literal. The pattern requires \"git log\" followed by a space, then anything. A bare <code>git log</code> with no trailing space won't match it. That's why <code>\"git log\"</code> (no wildcard) is also needed.</p>\n<h2>The Third Problem</h2>\n<p>Even after both fixes, it <em>still</em> prompted. I'd been eyeing the <code>tools</code> field suspiciously since the start. The config had both the deprecated <code>tools</code> block and the newer <code>permission</code> block.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">tools</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  read</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  write</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  edit</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  bash</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">permission</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  bash</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ask</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"grep *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span></code></pre>\n<p>The docs say <code>tools.bash: true</code> is equivalent to <code>{\"*\": \"allow\"}</code>. Having both <code>tools</code> and <code>permission</code> for the same thing seemed like it could cause conflicts — one saying \"allow everything\" and the other saying \"ask for everything except these.\" I don't know exactly how OpenCode resolves this internally, but removing the <code>tools</code> block entirely fixed the remaining issues.</p>\n<h2>The Working Config</h2>\n<p>The final config for the release-manager agent looks like this:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">description</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Create releases following Projex's version bump, changelog, and git tagging workflow</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">mode</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">subagent</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">temperature</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">0.1</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">permission</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  edit</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ask</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"packages/core/package.json\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"CHANGELOG.md\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"README.md\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"packages/core/README.md\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"packages/docs/**/*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  bash</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"*\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ask</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"pnpm --filter * build\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"pnpm --filter * lint\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"pnpm --filter * typecheck\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"pnpm --filter * test\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"head *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"ls *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"ls -la *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"find *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"grep *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"rg *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"cat *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"tail *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"wc *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"wc -l *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"sort *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"uniq *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git log *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git log\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git diff *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git diff\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git status *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git status\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"git tag *\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">allow</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  webfetch</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">deny</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  color</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">success</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">---</span></span></code></pre>\n<p>Three separate issues stacked on top of each other. Rule ordering, overly narrow patterns, and deprecated config conflicting with new config.</p>\n<h2>TL;DR</h2>\n<p>Three things I learned about OpenCode subagent permissions:</p>\n<ol>\n<li><strong>Catch-all goes first.</strong> <code>\"*\": ask</code> needs to be at the top of the permission block. Last matching rule wins.</li>\n<li><strong>Patterns need to match actual command syntax.</strong> <code>\"git log -- *\"</code> doesn't match <code>git log --oneline</code>. I had to use <code>\"git log *\"</code> instead.</li>\n<li><strong>Don't mix <code>tools</code> and <code>permission</code>.</strong> Removing the deprecated <code>tools</code> field fixed the remaining issues for me.</li>\n</ol>\n<p>I fixed the same issues in my <a href=\"https://github.com/ManningWorks/Projex/blob/main/.opencode/agents/documentation-manager.md\">documentation-manager agent</a> too. Both agents now run without prompting on every command. Which is how it should have been from the start.</p>",
            "date_modified": "2026-04-11T00:00:00.000Z",
            "tags": [
                "opencode"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/building-projex-retrospective",
            "content_html": "<p>I shipped an entire open source project and never documented a single day of building it.</p>\n<p>209 commits. February 21st to April 2nd. A full component library with a CLI, docs site, npm publishing pipeline, compound component API. And not one blog post while I was building any of it.</p>\n<p>This is the opposite of what I said I wanted to do.</p>\n<hr>\n<h2>How It Started</h2>\n<p>Projex started with a question I couldn't stop thinking about: how do I showcase my projects on my personal site in a structured way?</p>\n<p>I asked around. Did some research. Kept getting the same answer: most developers just build a custom solution. That struck me as strange. If everyone building a personal site needs a project showcase, why is everyone building the same thing from scratch?</p>\n<p>That gap felt like an opportunity.</p>\n<p>I had just redesigned my site with a terminal aesthetic and needed a projects page. The timing lined up. I was also looking for my first real OSS project, something with my name on it that other people could actually use.</p>\n<p>So I started building.</p>\n<hr>\n<h2>The First Two Days Were a Blur</h2>\n<p>Looking at the git log, the first two days were intense. February 21st alone has something like 30 commits. Monorepo scaffold with pnpm workspaces. TypeScript types. GitHub API integration. Config helper. Compound components. A full demo app.</p>\n<p>By the end of day one, I had the core architecture in place. That's not because I'm fast. It's because I had a clear picture of what I wanted before I started. The PRD was detailed enough that the initial implementation moved quickly.</p>\n<p>February 22nd was more of the same. npm registry support. Product Hunt integration. Layout components. Filter and sort utilities. Tests. Performance benchmarks. CI/CD pipeline.</p>\n<p>Two days in and I had a working product. That should have been the point where I wrote my first blog post. It wasn't.</p>\n<hr>\n<h2>The CLI Merge I Should Have Seen Coming</h2>\n<p>Early on, I split the project into separate CLI and core packages. Seemed like the right call at the time. Different concerns, different responsibilities, clean separation.</p>\n<p>A few days later I merged them back into a single package.</p>\n<p>The git log tells the story plainly: <code>refactor: merge CLI and core into single @reallukemanning/folio package</code>. No drama. No long agonising. Just a realisation that the separation was premature and added complexity I didn't need.</p>\n<p>Architecture decisions made on day one are expensive to undo. I learned that one firsthand. The merge itself wasn't painful, but it was a reminder to bias toward simplicity when possible. I could always split things later if I actually needed to.</p>\n<hr>\n<h2>VitePress on Vercel: The Silent 404s</h2>\n<p>The docs site was its own adventure.</p>\n<p>I built the documentation with VitePress and deployed it to Vercel. The build succeeded. Everything looked green. But the site returned 404s.</p>\n<p>Not helpful error messages. Not warnings during build. Just... 404s. Silent, unhelpful 404s.</p>\n<p>The git log from February 22nd tells the whole debugging arc:</p>\n<pre><code>fix: update VitePress output directory to point to src folder\nfix: copy VitePress assets to src directory for Vercel deployment\nrefactor: use Vercel rewrites instead of file copying\nfix: add rewrite for vp-icons.css at dist root\nfix: remove cleanUrls and rewrites, let Vercel handle routing\nfix: use copy-assets approach with all files in src/\nfix: copy all root-level files to src/ for Vercel\nfix: move HTML files to dist root for proper VitePress deployment\nfix: resolve Vercel 404s by moving all build files to dist root\nfix: add srcDir to VitePress config to resolve client-side 404s\n</code></pre>\n<p>Nine commits. Nine attempts. The problem was that VitePress and Vercel disagree about where built files should live and how routing should work. If your output directory or routing config is even slightly wrong, Vercel just silently 404s. No error. No hint. Just nothing.</p>\n<p>I tried file copying. Then Vercel rewrites. Then removing rewrites. Then different directory structures. What finally worked was setting <code>srcDir</code> in the VitePress config to get the build output in the right place.</p>\n<p>Obvious in hindsight. Everything is.</p>\n<hr>\n<h2>The OIDC Publishing Odyssey</h2>\n<p>This is the one that nearly broke me.</p>\n<p>Getting npm publishing working through GitHub Actions took from March 7th to March 8th. Two full days of failed publish attempts, each one a commit trying a different approach.</p>\n<p>The commit log from that stretch is almost comedy:</p>\n<pre><code>fix: use npm trusted publishing (remove NPM_TOKEN)\nfix: add id-token permission for trusted publishing\nfix: add registry-url for pnpm OIDC publishing\nfix: use npm directly for OIDC publishing\nrevert: use NPM_TOKEN like v1.7.1\nfix: use trusted publishing with pnpm\nfix: use NPM_TOKEN for publishing\nfix: use .npmrc for npm authentication\nfix: use npm with OIDC for publishing\nfix: add fetch mocking to npm-config tests to prevent CI timeout\nfix: use npm with OIDC (remove all token references)\nfix: configure npm registry and publish from correct directory\nfix: add --provenance flag for OIDC publishing\nfix: add repository field and upgrade npm for trusted publishing\nfix: use npm@11.5.1 and publish directly with npm\nfix: add contents:write permission for GitHub releases\n</code></pre>\n<p>I went back and forth between NPM_TOKEN and OIDC trusted publishing at least three times. The problem wasn't that OIDC is hard conceptually. The problem was that every combination of pnpm vs npm, registry-url config, provenance flags, and GitHub Actions permissions had its own subtle failure mode.</p>\n<p>And the error messages were unhelpful. npm publish failures tend to be vague. You'd get \"authentication required\" or \"unauthorized\" with no indication of whether the token was wrong, the registry URL was wrong, or the permissions were wrong.</p>\n<p>What finally worked: OIDC trusted publishing with the right combination of npm version (11.5.1 for provenance support), correct registry-url setup, and the <code>--provenance</code> flag. But getting there required trying basically every permutation.</p>\n<p>Was it worth it? Yeah. OIDC means no secrets to rotate, no tokens to leak, no NPM_TOKEN sitting in GitHub Actions secrets. But the setup pain was real.</p>\n<hr>\n<h2>The Rename: Folio to Projex</h2>\n<p>The project was originally called Folio. Package name <code>@reallukemanning/folio</code>. Everything was Folio for the first two weeks.</p>\n<p>Then on March 8th, I renamed the whole thing. <code>chore: rename Folio to Projex and repackage to @manningworks/projex</code>.</p>\n<p>A rename sounds simple. It wasn't. Every data attribute in every component changed from <code>data-folio-*</code> to <code>data-projex-*</code>. Every import path. Every reference in docs. The pnpm lockfile. The GitHub workflows. The CLI commands. The README.</p>\n<pre><code>chore: rename Folio to Projex and repackage to @manningworks/projex\nfix: update test files to use new data-projex-* attributes\nfix: remove all remaining Folio references\nfix: regenerate pnpm-lock.yaml with new package name\n</code></pre>\n<p>Four commits just for the rename, and that's not counting the docs updates.</p>\n<p>I renamed it because I wanted a more distinctive name. Folio is generic. Every portfolio project is called Folio. Projex felt more unique and more aligned with what I was actually building, a project showcase framework, not a portfolio template.</p>\n<p>The funny part: I didn't catch all the references. On April 2nd, nearly a month later, I was still fixing leftover Folio references: <code>fix: correct Folio→Projex branding, unscoped npx commands, and CSS variable names (v1.1.4)</code>.</p>\n<hr>\n<h2>What I Actually Built</h2>\n<p>209 commits later, Projex is:</p>\n<ul>\n<li>A shadcn-style component library with compound components (<code>ProjectCard.Header</code>, <code>ProjectCard.Stats</code>, etc.)</li>\n<li>A CLI that auto-discovers your GitHub repos with <code>npx @manningworks/projex init --github</code></li>\n<li>Build-time data fetching from GitHub, npm, and Product Hunt (no runtime API calls)</li>\n<li>Zero CSS shipped by default, style everything with data attributes</li>\n<li>Multiple project types: GitHub, npm, Product Hunt, YouTube, Gumroad, manual, hybrid</li>\n<li>A full VitePress docs site with interactive examples</li>\n<li>OIDC trusted publishing with automated GitHub Releases</li>\n<li>Vitest tests, ESLint, CI/CD</li>\n</ul>\n<p>It's used in production on my own site. It's on npm. The docs are live. It's real.</p>\n<hr>\n<h2>Why I Never Wrote About It</h2>\n<p>I don't have a great answer for this.</p>\n<p>Partly it was momentum. I was building fast and writing would have slowed me down. Partly it was the classic developer trap: I'll write about it when it's done. Then done kept moving. First it was \"done when the core works.\" Then \"done when I have docs.\" Then \"done when publishing works.\" Then \"done when I rename it.\" Then \"done when it's public on GitHub.\"</p>\n<p>Spoiler: it's never done.</p>\n<p>Partly it was the thing I wrote about in <a href=\"/blog/posting-into-the-void\">posting into the void</a>. I didn't want to write about something that no one was going to read. Which is backwards, because my blog is supposed to be documentation for myself first. I wrote a whole voice guide about this and then didn't follow my own advice.</p>\n<p>The irony of saying I want to build in public and then building an entire project in private isn't lost on me.</p>\n<hr>\n<h2>What I'd Do Differently</h2>\n<p>I'd write while building. Even short posts. Even rough notes. The OIDC publishing saga alone deserved its own post. The VitePress debugging arc would have been useful for someone else going through the same thing.</p>\n<p>The retrospective you're reading right now has less detail than it would have if I'd written it at the time. I'm reconstructing from git logs instead of writing from experience. That's a loss. The specific feeling of trying OIDC approach number seven at 11pm is gone. All I have is the commit message.</p>\n<p>I'd also have started simpler with the architecture. The split CLI/core package was premature. I knew that pretty quickly but it's still time I spent on something I undid.</p>\n<p>And I'd have picked the right name from the start.</p>\n<hr>\n<h2>Where Projex Is Now</h2>\n<p>Projex is at v1.1.4. It's on npm as <code>@manningworks/projex</code>. The docs are at <a href=\"https://projex.manningworks.dev\">projex.manningworks.dev</a>. The repo is public on GitHub.</p>\n<p>It hasn't set the world on fire. But it's real, it works, and I use it every day on my own site.</p>\n<p>The next step for me is to actually build in public going forward. Not as a brand strategy or a growth hack. Just as documentation of what I'm working on, as it happens. This post is the start of that, even if it's late.</p>\n<p>209 commits and not a single blog post. That's the antithesis of what I said I wanted to embody.</p>\n<p>Better late than never, I guess.</p>",
            "url": "https://lukemanning.ie/blog/building-projex-retrospective",
            "title": "I Built Projex and Never Wrote a Word About It",
            "summary": "<p>I shipped an entire open source project and never documented a single day of building it.</p>\n<p>209 commits. February 21st to April 2nd. A full component library with a CLI, docs site, npm publishing pipeline, compound component API. And not one blog post while I was building any of it.</p>\n<p>This is the opposite of what I said I wanted to do.</p>\n<hr>\n<h2>How It Started</h2>\n<p>Projex started with a question I couldn't stop thinking about: how do I showcase my projects on my personal site in a structured way?</p>\n<p>I asked around. Did some research. Kept getting the same answer: most developers just build a custom solution. That struck me as strange. If everyone building a personal site needs a project showcase, why is everyone building the same thing from scratch?</p>\n<p>That gap felt like an opportunity.</p>\n<p>I had just redesigned my site with a terminal aesthetic and needed a projects page. The timing lined up. I was also looking for my first real OSS project, something with my name on it that other people could actually use.</p>\n<p>So I started building.</p>\n<hr>\n<h2>The First Two Days Were a Blur</h2>\n<p>Looking at the git log, the first two days were intense. February 21st alone has something like 30 commits. Monorepo scaffold with pnpm workspaces. TypeScript types. GitHub API integration. Config helper. Compound components. A full demo app.</p>\n<p>By the end of day one, I had the core architecture in place. That's not because I'm fast. It's because I had a clear picture of what I wanted before I started. The PRD was detailed enough that the initial implementation moved quickly.</p>\n<p>February 22nd was more of the same. npm registry support. Product Hunt integration. Layout components. Filter and sort utilities. Tests. Performance benchmarks. CI/CD pipeline.</p>\n<p>Two days in and I had a working product. That should have been the point where I wrote my first blog post. It wasn't.</p>\n<hr>\n<h2>The CLI Merge I Should Have Seen Coming</h2>\n<p>Early on, I split the project into separate CLI and core packages. Seemed like the right call at the time. Different concerns, different responsibilities, clean separation.</p>\n<p>A few days later I merged them back into a single package.</p>\n<p>The git log tells the story plainly: <code>refactor: merge CLI and core into single @reallukemanning/folio package</code>. No drama. No long agonising. Just a realisation that the separation was premature and added complexity I didn't need.</p>\n<p>Architecture decisions made on day one are expensive to undo. I learned that one firsthand. The merge itself wasn't painful, but it was a reminder to bias toward simplicity when possible. I could always split things later if I actually needed to.</p>\n<hr>\n<h2>VitePress on Vercel: The Silent 404s</h2>\n<p>The docs site was its own adventure.</p>\n<p>I built the documentation with VitePress and deployed it to Vercel. The build succeeded. Everything looked green. But the site returned 404s.</p>\n<p>Not helpful error messages. Not warnings during build. Just... 404s. Silent, unhelpful 404s.</p>\n<p>The git log from February 22nd tells the whole debugging arc:</p>\n<pre><code>fix: update VitePress output directory to point to src folder\nfix: copy VitePress assets to src directory for Vercel deployment\nrefactor: use Vercel rewrites instead of file copying\nfix: add rewrite for vp-icons.css at dist root\nfix: remove cleanUrls and rewrites, let Vercel handle routing\nfix: use copy-assets approach with all files in src/\nfix: copy all root-level files to src/ for Vercel\nfix: move HTML files to dist root for proper VitePress deployment\nfix: resolve Vercel 404s by moving all build files to dist root\nfix: add srcDir to VitePress config to resolve client-side 404s\n</code></pre>\n<p>Nine commits. Nine attempts. The problem was that VitePress and Vercel disagree about where built files should live and how routing should work. If your output directory or routing config is even slightly wrong, Vercel just silently 404s. No error. No hint. Just nothing.</p>\n<p>I tried file copying. Then Vercel rewrites. Then removing rewrites. Then different directory structures. What finally worked was setting <code>srcDir</code> in the VitePress config to get the build output in the right place.</p>\n<p>Obvious in hindsight. Everything is.</p>\n<hr>\n<h2>The OIDC Publishing Odyssey</h2>\n<p>This is the one that nearly broke me.</p>\n<p>Getting npm publishing working through GitHub Actions took from March 7th to March 8th. Two full days of failed publish attempts, each one a commit trying a different approach.</p>\n<p>The commit log from that stretch is almost comedy:</p>\n<pre><code>fix: use npm trusted publishing (remove NPM_TOKEN)\nfix: add id-token permission for trusted publishing\nfix: add registry-url for pnpm OIDC publishing\nfix: use npm directly for OIDC publishing\nrevert: use NPM_TOKEN like v1.7.1\nfix: use trusted publishing with pnpm\nfix: use NPM_TOKEN for publishing\nfix: use .npmrc for npm authentication\nfix: use npm with OIDC for publishing\nfix: add fetch mocking to npm-config tests to prevent CI timeout\nfix: use npm with OIDC (remove all token references)\nfix: configure npm registry and publish from correct directory\nfix: add --provenance flag for OIDC publishing\nfix: add repository field and upgrade npm for trusted publishing\nfix: use npm@11.5.1 and publish directly with npm\nfix: add contents:write permission for GitHub releases\n</code></pre>\n<p>I went back and forth between NPM_TOKEN and OIDC trusted publishing at least three times. The problem wasn't that OIDC is hard conceptually. The problem was that every combination of pnpm vs npm, registry-url config, provenance flags, and GitHub Actions permissions had its own subtle failure mode.</p>\n<p>And the error messages were unhelpful. npm publish failures tend to be vague. You'd get \"authentication required\" or \"unauthorized\" with no indication of whether the token was wrong, the registry URL was wrong, or the permissions were wrong.</p>\n<p>What finally worked: OIDC trusted publishing with the right combination of npm version (11.5.1 for provenance support), correct registry-url setup, and the <code>--provenance</code> flag. But getting there required trying basically every permutation.</p>\n<p>Was it worth it? Yeah. OIDC means no secrets to rotate, no tokens to leak, no NPM_TOKEN sitting in GitHub Actions secrets. But the setup pain was real.</p>\n<hr>\n<h2>The Rename: Folio to Projex</h2>\n<p>The project was originally called Folio. Package name <code>@reallukemanning/folio</code>. Everything was Folio for the first two weeks.</p>\n<p>Then on March 8th, I renamed the whole thing. <code>chore: rename Folio to Projex and repackage to @manningworks/projex</code>.</p>\n<p>A rename sounds simple. It wasn't. Every data attribute in every component changed from <code>data-folio-*</code> to <code>data-projex-*</code>. Every import path. Every reference in docs. The pnpm lockfile. The GitHub workflows. The CLI commands. The README.</p>\n<pre><code>chore: rename Folio to Projex and repackage to @manningworks/projex\nfix: update test files to use new data-projex-* attributes\nfix: remove all remaining Folio references\nfix: regenerate pnpm-lock.yaml with new package name\n</code></pre>\n<p>Four commits just for the rename, and that's not counting the docs updates.</p>\n<p>I renamed it because I wanted a more distinctive name. Folio is generic. Every portfolio project is called Folio. Projex felt more unique and more aligned with what I was actually building, a project showcase framework, not a portfolio template.</p>\n<p>The funny part: I didn't catch all the references. On April 2nd, nearly a month later, I was still fixing leftover Folio references: <code>fix: correct Folio→Projex branding, unscoped npx commands, and CSS variable names (v1.1.4)</code>.</p>\n<hr>\n<h2>What I Actually Built</h2>\n<p>209 commits later, Projex is:</p>\n<ul>\n<li>A shadcn-style component library with compound components (<code>ProjectCard.Header</code>, <code>ProjectCard.Stats</code>, etc.)</li>\n<li>A CLI that auto-discovers your GitHub repos with <code>npx @manningworks/projex init --github</code></li>\n<li>Build-time data fetching from GitHub, npm, and Product Hunt (no runtime API calls)</li>\n<li>Zero CSS shipped by default, style everything with data attributes</li>\n<li>Multiple project types: GitHub, npm, Product Hunt, YouTube, Gumroad, manual, hybrid</li>\n<li>A full VitePress docs site with interactive examples</li>\n<li>OIDC trusted publishing with automated GitHub Releases</li>\n<li>Vitest tests, ESLint, CI/CD</li>\n</ul>\n<p>It's used in production on my own site. It's on npm. The docs are live. It's real.</p>\n<hr>\n<h2>Why I Never Wrote About It</h2>\n<p>I don't have a great answer for this.</p>\n<p>Partly it was momentum. I was building fast and writing would have slowed me down. Partly it was the classic developer trap: I'll write about it when it's done. Then done kept moving. First it was \"done when the core works.\" Then \"done when I have docs.\" Then \"done when publishing works.\" Then \"done when I rename it.\" Then \"done when it's public on GitHub.\"</p>\n<p>Spoiler: it's never done.</p>\n<p>Partly it was the thing I wrote about in <a href=\"/blog/posting-into-the-void\">posting into the void</a>. I didn't want to write about something that no one was going to read. Which is backwards, because my blog is supposed to be documentation for myself first. I wrote a whole voice guide about this and then didn't follow my own advice.</p>\n<p>The irony of saying I want to build in public and then building an entire project in private isn't lost on me.</p>\n<hr>\n<h2>What I'd Do Differently</h2>\n<p>I'd write while building. Even short posts. Even rough notes. The OIDC publishing saga alone deserved its own post. The VitePress debugging arc would have been useful for someone else going through the same thing.</p>\n<p>The retrospective you're reading right now has less detail than it would have if I'd written it at the time. I'm reconstructing from git logs instead of writing from experience. That's a loss. The specific feeling of trying OIDC approach number seven at 11pm is gone. All I have is the commit message.</p>\n<p>I'd also have started simpler with the architecture. The split CLI/core package was premature. I knew that pretty quickly but it's still time I spent on something I undid.</p>\n<p>And I'd have picked the right name from the start.</p>\n<hr>\n<h2>Where Projex Is Now</h2>\n<p>Projex is at v1.1.4. It's on npm as <code>@manningworks/projex</code>. The docs are at <a href=\"https://projex.manningworks.dev\">projex.manningworks.dev</a>. The repo is public on GitHub.</p>\n<p>It hasn't set the world on fire. But it's real, it works, and I use it every day on my own site.</p>\n<p>The next step for me is to actually build in public going forward. Not as a brand strategy or a growth hack. Just as documentation of what I'm working on, as it happens. This post is the start of that, even if it's late.</p>\n<p>209 commits and not a single blog post. That's the antithesis of what I said I wanted to embody.</p>\n<p>Better late than never, I guess.</p>",
            "date_modified": "2026-04-10T00:00:00.000Z",
            "tags": [
                "projex"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/the-hobby-isolation-paradox",
            "content_html": "<p>There's a specific kind of exhaustion that comes with being the tech person in your family.</p>\n<p>It's not the fixing. You can fix things. It's the explaining. Bridging the gap between what's actually happening and what will land for the person standing over your shoulder. You get good at simplifying. Fast at it. But after a while the simplifying starts to feel like a tax. And eventually, without really noticing, you stop bringing certain things up at all. Not because people don't care about you. But because you already know how it ends.</p>\n<hr>\n<p>Hobbies are supposed to fix this.</p>\n<p>Common ground. A talking point. Something to bond over. The implicit promise of a hobby is that it connects you to people — other enthusiasts, curious friends, family members who ask how it's going. For most hobbies that promise holds. You pick up candle making and suddenly you have something easy to talk about at dinner. Everyone knows what a candle is. The vocabulary is shared, the concept of enjoying it makes intuitive sense.</p>\n<p>But some hobbies break that promise entirely.</p>\n<p>Right now the thing I'm most excited about is agentic engineering — building software by collaborating with AI. Not using it as a shortcut. Using it as a creative partner. It's what I think about when I'm not working. What I open my laptop for on a Saturday morning before I've had coffee.</p>\n<p>And almost nobody in my day-to-day life gets it.</p>\n<p>I've tried explaining it. Someone asks what I've been up to, and I start with \"building software with AI\" and I can see it happen — the polite nod, the slight glaze. Not because they don't care. They're trying. But the words don't land. \"AI agent\" means nothing to most people. \"Prompt engineering\" sounds like corporate jargon. Before I can get to the interesting part I'm already three layers deep in background, and the conversation has moved on.</p>\n<p>Even when I find the right simplification, there's another wall waiting. Because building software on a Saturday morning doesn't register as a hobby. It sounds like work. The idea that it could be play — that it's the thing I look forward to all week — that part doesn't compute.</p>\n<p>So I stop bringing it up. Or I start to, feel what's coming, and just let it go.</p>\n<p>The thing that was supposed to give me community becomes the thing I can't even mention.</p>\n<hr>\n<p>Recently someone I work with mentioned Claude Code in passing. Just dropped it into conversation like it was nothing. The same energy, the same excitement, that I'd been wanting to express. We were already two steps into a conversation I didn't have to set up.</p>\n<p>That's part of why I write here. Not to explain things to people who don't get it — but to find the ones who already do. The builder who's also lying awake thinking about what's coming. The person who gets why this stuff is fun, not just useful.</p>\n<p>On the flip side: <a href=\"/blog/same-content-different-job\">being island-like with your content means it stops compounding</a>. The silence isn't just from nobody reading — it can also be because the work itself isn't wired to pull people deeper.</p>",
            "url": "https://lukemanning.ie/blog/the-hobby-isolation-paradox",
            "title": "The Hobby Isolation Paradox",
            "summary": "<p>There's a specific kind of exhaustion that comes with being the tech person in your family.</p>\n<p>It's not the fixing. You can fix things. It's the explaining. Bridging the gap between what's actually happening and what will land for the person standing over your shoulder. You get good at simplifying. Fast at it. But after a while the simplifying starts to feel like a tax. And eventually, without really noticing, you stop bringing certain things up at all. Not because people don't care about you. But because you already know how it ends.</p>\n<hr>\n<p>Hobbies are supposed to fix this.</p>\n<p>Common ground. A talking point. Something to bond over. The implicit promise of a hobby is that it connects you to people — other enthusiasts, curious friends, family members who ask how it's going. For most hobbies that promise holds. You pick up candle making and suddenly you have something easy to talk about at dinner. Everyone knows what a candle is. The vocabulary is shared, the concept of enjoying it makes intuitive sense.</p>\n<p>But some hobbies break that promise entirely.</p>\n<p>Right now the thing I'm most excited about is agentic engineering — building software by collaborating with AI. Not using it as a shortcut. Using it as a creative partner. It's what I think about when I'm not working. What I open my laptop for on a Saturday morning before I've had coffee.</p>\n<p>And almost nobody in my day-to-day life gets it.</p>\n<p>I've tried explaining it. Someone asks what I've been up to, and I start with \"building software with AI\" and I can see it happen — the polite nod, the slight glaze. Not because they don't care. They're trying. But the words don't land. \"AI agent\" means nothing to most people. \"Prompt engineering\" sounds like corporate jargon. Before I can get to the interesting part I'm already three layers deep in background, and the conversation has moved on.</p>\n<p>Even when I find the right simplification, there's another wall waiting. Because building software on a Saturday morning doesn't register as a hobby. It sounds like work. The idea that it could be play — that it's the thing I look forward to all week — that part doesn't compute.</p>\n<p>So I stop bringing it up. Or I start to, feel what's coming, and just let it go.</p>\n<p>The thing that was supposed to give me community becomes the thing I can't even mention.</p>\n<hr>\n<p>Recently someone I work with mentioned Claude Code in passing. Just dropped it into conversation like it was nothing. The same energy, the same excitement, that I'd been wanting to express. We were already two steps into a conversation I didn't have to set up.</p>\n<p>That's part of why I write here. Not to explain things to people who don't get it — but to find the ones who already do. The builder who's also lying awake thinking about what's coming. The person who gets why this stuff is fun, not just useful.</p>\n<p>On the flip side: <a href=\"/blog/same-content-different-job\">being island-like with your content means it stops compounding</a>. The silence isn't just from nobody reading — it can also be because the work itself isn't wired to pull people deeper.</p>",
            "date_modified": "2026-04-09T00:00:00.000Z",
            "tags": [
                "reflections"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/syncthing-obsidian-vault-sync",
            "content_html": "<p>Obsidian Sync costs up to $120 a year if you're on Sync Plus, and paying monthly. That's the number that made me actually do something about it.</p>\n<p>I was curious about using Obsidian Sync because people generally were happy that it worked. At least people on reddit were. However I did not want yet another subscription. As much as I like the work that Obsidian do, I just couldn't justify it.</p>\n<p>I went with Syncthing because it's peer-to-peer (no third-party server holding my files), it's free, and I've read several threads and watched a few YouTube videos where people swear by it. My always-on NucBox (Ubuntu 24.04, headless) acts as the hub. Windows desktop and Android phone sync to it. I genuinely forget it's there most of the time.</p>\n<p>This post isn't a tutorial. The Syncthing docs are fine. What I kept running into was the specific problem of configuring a headless Linux machine without a browser, and nobody really spelled that part out clearly. So here's my setup, including the bits I had to figure out myself.</p>\n<h2>Getting to the Web UI</h2>\n<p>Most Syncthing guides assume you can just open <code>localhost:8384</code> in a browser on the same machine running Syncthing. My NucBox doesn't have a browser. Or a screen. It's just a small fanless PC sitting under my desk running Ubuntu without a Desktop environment.</p>\n<p>My first thought was to install a VNC server or something. I wasn't a fan of that approach and after some more time Googling around I eventually found the SSH tunnel approach, which is way simpler than I expected.</p>\n<p>Syncthing's web UI runs on port 8384 on the NucBox. I mapped that to a local port on my Windows machine over SSH:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">ssh</span><span style=\"color:#79B8FF\"> -L</span><span style=\"color:#9ECBFF\"> 9384:localhost:8384</span><span style=\"color:#9ECBFF\"> luke@</span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#9ECBFF\">nucbox-i</span><span style=\"color:#E1E4E8\">p</span><span style=\"color:#F97583\">></span><span style=\"color:#79B8FF\"> -N</span></span></code></pre>\n<p>Then opened <code>localhost:9384</code> in my browser on my desktop. Syncthing defaults to no auth on the web UI, and I didn't love the idea of an unauthenticated interface accessible over the network. However given this was all running on localhost, I figured that was a problem for another day.</p>\n<p>I was already running Syncthing on Windows, which was using port 8384. That's why I mapped to 9384 instead. Anything unused works.</p>\n<h2>What I Ran on the NucBox</h2>\n<p>Three commands:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">sudo</span><span style=\"color:#9ECBFF\"> apt</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#9ECBFF\"> syncthing</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">systemctl</span><span style=\"color:#79B8FF\"> --user</span><span style=\"color:#9ECBFF\"> enable</span><span style=\"color:#9ECBFF\"> syncthing</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">systemctl</span><span style=\"color:#79B8FF\"> --user</span><span style=\"color:#9ECBFF\"> start</span><span style=\"color:#9ECBFF\"> syncthing</span></span></code></pre>\n<p>After those, I checked the service was actually running with <code>systemctl --user status syncthing</code>. Active and running. That was my \"okay, it's alive\" moment.</p>\n<p>Actually, one thing I assumed I'd have to deal with. <code>systemctl --user</code> services on a headless machine don't survive logout unless you enable \"lingering.\" Without it, Syncthing stops the second you close your SSH session. I checked mine:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">loginctl</span><span style=\"color:#9ECBFF\"> show-user</span><span style=\"color:#9ECBFF\"> luke</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> grep</span><span style=\"color:#9ECBFF\"> Linger</span></span></code></pre>\n<p>Turns out it was already enabled (<code>Linger=yes</code>). I'm guessing something during the Ubuntu install set it up — I definitely never ran <code>loginctl enable-linger</code> myself. But if yours shows <code>Linger=no</code>, you'll need:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">loginctl</span><span style=\"color:#9ECBFF\"> enable-linger</span><span style=\"color:#9ECBFF\"> luke</span></span></code></pre>\n<p>This is probably the most headless-specific gotcha in the whole setup. And I suspect a lot of guides skip it because they assume you have a desktop login session.</p>\n<p>The Ubuntu package is fine. I didn't need any third-party repos.</p>\n<p>Once the tunnel was up it was the standard Syncthing workflow. I added a folder, pointed it at my vault, and shared it with my other devices. I installed Syncthing-Fork on Android from the F-Droid store. Paired devices using the Device ID from <strong>Actions → Show ID</strong>.</p>\n<p>The Android app can scan a QR code from that same screen, which is much easier than typing out a 60-character device ID by hand. It can also discover other Syncthing devices on the same network, which was super useful for me.</p>\n<h2>The .stignore Thing</h2>\n<p>I'd been running Syncthing for maybe a day when I started seeing orange warning triangles in the UI. Low-level conflicts on <code>.obsidian/workspace.json</code> and <code>.obsidian/workspace-mobile.json</code>. Nothing catastrophic, but it was noise I didn't need.</p>\n<p>Turns out Obsidian rewrites those files every time you open it — they store open tabs, cursor positions, that kind of thing. Different on every device, changing constantly. No wonder Syncthing was confused.</p>\n<p>A bit of searching told me about <code>.stignore</code>. I added this at the root of the vault:</p>\n<pre><code>.obsidian/workspace.json\n.obsidian/workspace-mobile.json\n.trash/\n</code></pre>\n<p>The rest of <code>.obsidian/</code> (plugins, themes, settings) syncs fine. That's the stuff I actually want consistent across machines.</p>\n<h2>A Few Weeks In</h2>\n<p>On the same LAN it syncs in seconds. Over the internet it handles NAT traversal automatically. I didn't have to configure any port forwarding or open firewall ports. The NucBox isn't running ufw, so there was nothing to touch there. It just worked.</p>\n<p>Syncthing apparently used to use relay servers for this, but they were removed a while back (I think around v1.27). Whatever it's doing now, I didn't have to think about it.</p>\n<p>I've had this running for a few days now across the NucBox, my Windows desktop, and my Android phone. I haven't thought about it once since setting it up, which is exactly what I wanted.</p>\n<p>The $120 a year I was considering paying Obsidian Sync? Going toward something else now.</p>",
            "url": "https://lukemanning.ie/blog/syncthing-obsidian-vault-sync",
            "title": "Dropping Obsidian Sync for Syncthing: What I Learned Setting Up a Headless Linux Sync Hub",
            "summary": "<p>Obsidian Sync costs up to $120 a year if you're on Sync Plus, and paying monthly. That's the number that made me actually do something about it.</p>\n<p>I was curious about using Obsidian Sync because people generally were happy that it worked. At least people on reddit were. However I did not want yet another subscription. As much as I like the work that Obsidian do, I just couldn't justify it.</p>\n<p>I went with Syncthing because it's peer-to-peer (no third-party server holding my files), it's free, and I've read several threads and watched a few YouTube videos where people swear by it. My always-on NucBox (Ubuntu 24.04, headless) acts as the hub. Windows desktop and Android phone sync to it. I genuinely forget it's there most of the time.</p>\n<p>This post isn't a tutorial. The Syncthing docs are fine. What I kept running into was the specific problem of configuring a headless Linux machine without a browser, and nobody really spelled that part out clearly. So here's my setup, including the bits I had to figure out myself.</p>\n<h2>Getting to the Web UI</h2>\n<p>Most Syncthing guides assume you can just open <code>localhost:8384</code> in a browser on the same machine running Syncthing. My NucBox doesn't have a browser. Or a screen. It's just a small fanless PC sitting under my desk running Ubuntu without a Desktop environment.</p>\n<p>My first thought was to install a VNC server or something. I wasn't a fan of that approach and after some more time Googling around I eventually found the SSH tunnel approach, which is way simpler than I expected.</p>\n<p>Syncthing's web UI runs on port 8384 on the NucBox. I mapped that to a local port on my Windows machine over SSH:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">ssh</span><span style=\"color:#79B8FF\"> -L</span><span style=\"color:#9ECBFF\"> 9384:localhost:8384</span><span style=\"color:#9ECBFF\"> luke@</span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#9ECBFF\">nucbox-i</span><span style=\"color:#E1E4E8\">p</span><span style=\"color:#F97583\">></span><span style=\"color:#79B8FF\"> -N</span></span></code></pre>\n<p>Then opened <code>localhost:9384</code> in my browser on my desktop. Syncthing defaults to no auth on the web UI, and I didn't love the idea of an unauthenticated interface accessible over the network. However given this was all running on localhost, I figured that was a problem for another day.</p>\n<p>I was already running Syncthing on Windows, which was using port 8384. That's why I mapped to 9384 instead. Anything unused works.</p>\n<h2>What I Ran on the NucBox</h2>\n<p>Three commands:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">sudo</span><span style=\"color:#9ECBFF\"> apt</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#9ECBFF\"> syncthing</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">systemctl</span><span style=\"color:#79B8FF\"> --user</span><span style=\"color:#9ECBFF\"> enable</span><span style=\"color:#9ECBFF\"> syncthing</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">systemctl</span><span style=\"color:#79B8FF\"> --user</span><span style=\"color:#9ECBFF\"> start</span><span style=\"color:#9ECBFF\"> syncthing</span></span></code></pre>\n<p>After those, I checked the service was actually running with <code>systemctl --user status syncthing</code>. Active and running. That was my \"okay, it's alive\" moment.</p>\n<p>Actually, one thing I assumed I'd have to deal with. <code>systemctl --user</code> services on a headless machine don't survive logout unless you enable \"lingering.\" Without it, Syncthing stops the second you close your SSH session. I checked mine:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">loginctl</span><span style=\"color:#9ECBFF\"> show-user</span><span style=\"color:#9ECBFF\"> luke</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> grep</span><span style=\"color:#9ECBFF\"> Linger</span></span></code></pre>\n<p>Turns out it was already enabled (<code>Linger=yes</code>). I'm guessing something during the Ubuntu install set it up — I definitely never ran <code>loginctl enable-linger</code> myself. But if yours shows <code>Linger=no</code>, you'll need:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">loginctl</span><span style=\"color:#9ECBFF\"> enable-linger</span><span style=\"color:#9ECBFF\"> luke</span></span></code></pre>\n<p>This is probably the most headless-specific gotcha in the whole setup. And I suspect a lot of guides skip it because they assume you have a desktop login session.</p>\n<p>The Ubuntu package is fine. I didn't need any third-party repos.</p>\n<p>Once the tunnel was up it was the standard Syncthing workflow. I added a folder, pointed it at my vault, and shared it with my other devices. I installed Syncthing-Fork on Android from the F-Droid store. Paired devices using the Device ID from <strong>Actions → Show ID</strong>.</p>\n<p>The Android app can scan a QR code from that same screen, which is much easier than typing out a 60-character device ID by hand. It can also discover other Syncthing devices on the same network, which was super useful for me.</p>\n<h2>The .stignore Thing</h2>\n<p>I'd been running Syncthing for maybe a day when I started seeing orange warning triangles in the UI. Low-level conflicts on <code>.obsidian/workspace.json</code> and <code>.obsidian/workspace-mobile.json</code>. Nothing catastrophic, but it was noise I didn't need.</p>\n<p>Turns out Obsidian rewrites those files every time you open it — they store open tabs, cursor positions, that kind of thing. Different on every device, changing constantly. No wonder Syncthing was confused.</p>\n<p>A bit of searching told me about <code>.stignore</code>. I added this at the root of the vault:</p>\n<pre><code>.obsidian/workspace.json\n.obsidian/workspace-mobile.json\n.trash/\n</code></pre>\n<p>The rest of <code>.obsidian/</code> (plugins, themes, settings) syncs fine. That's the stuff I actually want consistent across machines.</p>\n<h2>A Few Weeks In</h2>\n<p>On the same LAN it syncs in seconds. Over the internet it handles NAT traversal automatically. I didn't have to configure any port forwarding or open firewall ports. The NucBox isn't running ufw, so there was nothing to touch there. It just worked.</p>\n<p>Syncthing apparently used to use relay servers for this, but they were removed a while back (I think around v1.27). Whatever it's doing now, I didn't have to think about it.</p>\n<p>I've had this running for a few days now across the NucBox, my Windows desktop, and my Android phone. I haven't thought about it once since setting it up, which is exactly what I wanted.</p>\n<p>The $120 a year I was considering paying Obsidian Sync? Going toward something else now.</p>",
            "date_modified": "2026-04-08T00:00:00.000Z",
            "tags": [
                "homelab",
                "obsidian",
                "ubuntu"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/same-content-different-job",
            "content_html": "<p>I found a Reddit post this week. Classic AI waffle: income streams, compound interest metaphors, a tidy 26-month arc with conveniently spaced milestones. You've seen the format.</p>\n<p>It caught me at the right moment — I'd been thinking about whether my own posting was compounding or just accumulating.</p>\n<p>Buried in the noise was something worth keeping: the silence is the deposit phase.</p>\n<p>The idea being that the first year of building in public (when nobody's watching, nothing's ranking, and your posts feel like they're disappearing into a void) isn't failure. It's just early. The compounding hasn't had time to show up yet.</p>\n<p>I buy that. It maps to everything I've seen from people who actually made it work long-term. But \"just keep depositing\" has a gap. It assumes all deposits are equal. They're not.</p>\n<hr>\n<h2>Consistency isn't the whole answer</h2>\n<p>Most build-in-public advice stops at \"just keep showing up.\" Post consistently. Play the long game. Trust the process.</p>\n<p>Fine. But <em>what</em> you're building with that consistency matters as much as the consistency itself.</p>\n<p>I looked at my own setup recently. Blog here, X account there, GitHub with projects that don't link back. Each one technically exists. None of them point at each other. Someone finding my X wouldn't know about the blog. Someone reading the blog wouldn't know about the projects.</p>\n<p>That's when it clicked — isolated posts don't compound, they just pile up. The ones that build on each other, reference each other, pull a reader deeper — those are the ones that do.</p>\n<p>There's a difference between a large archive and a library. An archive is a pile. A library has structure. Things reference each other, ideas build on ideas, a reader lands on one post and the whole thing pulls them deeper.</p>\n<p>My stuff right now is more archive than library. It's easy to end up here, especially early on when you're still figuring out what you're even building.</p>\n<p>But the library problem isn't just about posts linking to posts internally. Each platform also needs the content to do different work depending on where it lives.</p>\n<hr>\n<h2>Platforms aren't just audiences. They're different jobs.</h2>\n<p>I shipped a component library recently: <a href=\"https://github.com/ManningWorks/Projex\">Projex</a>, a shadcn-style showcase kit for developer portfolios. One project, multiple surfaces.</p>\n<p>I posted about it on X — a rough thread, a screenshot, a \"shipped this\" post at midnight. Stream of consciousness, move on. Low barrier, builder brain energy.</p>\n<p>The blog version is slower. What I learned building it, the tradeoffs, the decisions I'd make differently. Something that earns a bookmark rather than a like.</p>\n<p>LinkedIn is where I had to think harder. Nobody there cares about the npm package. But the gap between \"good enough for work\" and \"good enough that strangers might actually use it\" — what building something in public taught me about shipping — that's what LinkedIn cares about. That's the version that makes sense there.</p>\n<p>Same source material. Three different jobs.</p>\n<p>Not every piece of raw material has all three versions. Some things are only X-native. A half-formed thought, a tool I'm trying, a response to something in the timeline. That doesn't have a LinkedIn version. Forcing one would be obvious and grim.</p>\n<p>I've caught myself about to cross-post things that would make no sense outside the X timeline. It's \"builder brain at midnight\" energy. It stays on X.</p>\n<hr>\n<h2>Where I actually am with this</h2>\n<p>I'm working this out in real time.</p>\n<p>My LinkedIn has nothing on it right now. No evidence of building. I haven't figured out the voice for it yet, and I've been cautious about the line between \"here's what I'm learning\" and \"here's what I do at work.\" Those aren't the same thing, but they can look the same from the outside if you're not careful.</p>\n<p>My X is random. Building observations, occasional blog posts, no obvious throughline if you landed there cold.</p>\n<p>My site is growing slowly. Projects, posts, a Now page. The identity is there if you spend time with it, but it's not obvious yet.</p>\n<p>And I think that's fine. I think coherence comes from volume. You can't see the throughline when you've written fifteen posts. It emerges, both for you and for whoever's reading. The job right now isn't to have it all figured out. It's to keep building the library, be intentional about the links between things, and trust that the shape of it becomes clearer over time.</p>\n<p>The silence is still the deposit phase. I <a href=\"/blog/posting-into-the-void\">wrote about what that feels like</a>. And <a href=\"/blog/the-entrepreneurial-conundrum\">the entrepreneurial gap</a> — between having ideas and actually shipping them — is part of what I'm trying to close. Some of <a href=\"/blog/the-hobby-isolation-paradox\">the isolation paradox</a> applies here too: the thing I'm most excited about is hard to talk about anywhere except here. I just want to make sure the deposits are going into the right accounts.</p>\n<hr>\n<p><em>Building in public at <a href=\"https://lukemanning.ie\">lukemanning.ie</a>. Occasionally coherent on <a href=\"https://x.com\">X</a>.</em></p>",
            "url": "https://lukemanning.ie/blog/same-content-different-job",
            "title": "Same Content, Different Job",
            "summary": "<p>I found a Reddit post this week. Classic AI waffle: income streams, compound interest metaphors, a tidy 26-month arc with conveniently spaced milestones. You've seen the format.</p>\n<p>It caught me at the right moment — I'd been thinking about whether my own posting was compounding or just accumulating.</p>\n<p>Buried in the noise was something worth keeping: the silence is the deposit phase.</p>\n<p>The idea being that the first year of building in public (when nobody's watching, nothing's ranking, and your posts feel like they're disappearing into a void) isn't failure. It's just early. The compounding hasn't had time to show up yet.</p>\n<p>I buy that. It maps to everything I've seen from people who actually made it work long-term. But \"just keep depositing\" has a gap. It assumes all deposits are equal. They're not.</p>\n<hr>\n<h2>Consistency isn't the whole answer</h2>\n<p>Most build-in-public advice stops at \"just keep showing up.\" Post consistently. Play the long game. Trust the process.</p>\n<p>Fine. But <em>what</em> you're building with that consistency matters as much as the consistency itself.</p>\n<p>I looked at my own setup recently. Blog here, X account there, GitHub with projects that don't link back. Each one technically exists. None of them point at each other. Someone finding my X wouldn't know about the blog. Someone reading the blog wouldn't know about the projects.</p>\n<p>That's when it clicked — isolated posts don't compound, they just pile up. The ones that build on each other, reference each other, pull a reader deeper — those are the ones that do.</p>\n<p>There's a difference between a large archive and a library. An archive is a pile. A library has structure. Things reference each other, ideas build on ideas, a reader lands on one post and the whole thing pulls them deeper.</p>\n<p>My stuff right now is more archive than library. It's easy to end up here, especially early on when you're still figuring out what you're even building.</p>\n<p>But the library problem isn't just about posts linking to posts internally. Each platform also needs the content to do different work depending on where it lives.</p>\n<hr>\n<h2>Platforms aren't just audiences. They're different jobs.</h2>\n<p>I shipped a component library recently: <a href=\"https://github.com/ManningWorks/Projex\">Projex</a>, a shadcn-style showcase kit for developer portfolios. One project, multiple surfaces.</p>\n<p>I posted about it on X — a rough thread, a screenshot, a \"shipped this\" post at midnight. Stream of consciousness, move on. Low barrier, builder brain energy.</p>\n<p>The blog version is slower. What I learned building it, the tradeoffs, the decisions I'd make differently. Something that earns a bookmark rather than a like.</p>\n<p>LinkedIn is where I had to think harder. Nobody there cares about the npm package. But the gap between \"good enough for work\" and \"good enough that strangers might actually use it\" — what building something in public taught me about shipping — that's what LinkedIn cares about. That's the version that makes sense there.</p>\n<p>Same source material. Three different jobs.</p>\n<p>Not every piece of raw material has all three versions. Some things are only X-native. A half-formed thought, a tool I'm trying, a response to something in the timeline. That doesn't have a LinkedIn version. Forcing one would be obvious and grim.</p>\n<p>I've caught myself about to cross-post things that would make no sense outside the X timeline. It's \"builder brain at midnight\" energy. It stays on X.</p>\n<hr>\n<h2>Where I actually am with this</h2>\n<p>I'm working this out in real time.</p>\n<p>My LinkedIn has nothing on it right now. No evidence of building. I haven't figured out the voice for it yet, and I've been cautious about the line between \"here's what I'm learning\" and \"here's what I do at work.\" Those aren't the same thing, but they can look the same from the outside if you're not careful.</p>\n<p>My X is random. Building observations, occasional blog posts, no obvious throughline if you landed there cold.</p>\n<p>My site is growing slowly. Projects, posts, a Now page. The identity is there if you spend time with it, but it's not obvious yet.</p>\n<p>And I think that's fine. I think coherence comes from volume. You can't see the throughline when you've written fifteen posts. It emerges, both for you and for whoever's reading. The job right now isn't to have it all figured out. It's to keep building the library, be intentional about the links between things, and trust that the shape of it becomes clearer over time.</p>\n<p>The silence is still the deposit phase. I <a href=\"/blog/posting-into-the-void\">wrote about what that feels like</a>. And <a href=\"/blog/the-entrepreneurial-conundrum\">the entrepreneurial gap</a> — between having ideas and actually shipping them — is part of what I'm trying to close. Some of <a href=\"/blog/the-hobby-isolation-paradox\">the isolation paradox</a> applies here too: the thing I'm most excited about is hard to talk about anywhere except here. I just want to make sure the deposits are going into the right accounts.</p>\n<hr>\n<p><em>Building in public at <a href=\"https://lukemanning.ie\">lukemanning.ie</a>. Occasionally coherent on <a href=\"https://x.com\">X</a>.</em></p>",
            "date_modified": "2026-04-07T00:00:00.000Z",
            "tags": [
                "reflections"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/building-a-reading-companion-for-my-vault",
            "content_html": "<p>I read a lot. Not in a flex way but more like a borderline problem. The problem is none of it sticks. I close the book, move on, and six months later I remember almost nothing. Not the ideas, not the context, barely even the thesis.</p>\n<p>My vault lives on Syncthing now — <a href=\"/blog/syncthing-obsidian-vault-sync\">I moved away from Obsidian Sync to cut the cost</a>. Having my vault synced across machines without a subscription made it more viable as a daily tool.</p>\n<p>I tried highlighting. I tried taking notes on my phone while reading. Neither worked for me. Highlights become a graveyard of yellow that I never revisit, and notes are too spontaneous to build anything coherent.</p>\n<p>What I wanted was a companion. Someone to actually <em>discuss</em> what I'm reading with. Not summarize the book back at me, but pull it apart, connect it to weird stuff, make it memorable.</p>\n<p>So I built one. It runs as a skill in my Hermes Agent setup and the enriched data gets stored in my Obsidian Vault.</p>\n<h2>What It Does</h2>\n<p>The reading companion is a skill I can trigger whenever I'm reading something that hits me as interesting, complex, or just worth sitting with. I paste a passage, and it comes back enriched across five areas:</p>\n<ol>\n<li>Historical context — what was happening when this was written, who the author was influenced by, related events</li>\n<li>Pop culture and internet references — viral moments, memes, Reddit threads, movies that tangentially connect</li>\n<li>Books and literature — other works that echo the same ideas</li>\n<li>Unexpected trivia — science, psychology, language, whatever weirdly relevant fact surfaced</li>\n<li>Emotional and thematic insight — what the passage is actually <em>about</em> underneath the words</li>\n</ol>\n<p>The goal isn't a book report. It's more like having a smart friend who makes unexpected connections and gets genuinely excited about ideas.</p>\n<h2>Integration With the Vault</h2>\n<p>This is where it clicked for me. The enriched passages go straight into <code>~/vault/books/&#x3C;book-slug>.md</code>. One note per book, entries appended as I read.</p>\n<p>So instead of highlights scattered across a Kindle or margins that never talk to each other, I have a running document that traces my journey through a single book. Each entry has the passage, the context, and the date. When I finish the book, I have a map of every moment that made me stop and think.</p>\n<p>The vault schema already had a slot for this. The reading-companion skill fills it. Here's an example from <em>The Dark Forest</em> by Liu Cixin:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Passage — Chapter 9 (2025-04-13)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> \"He had returned countless times to these words, analyzing each</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> sentence from every angle and chewing over every word. The component</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> words had been strung into a set of prayer beads, and like a pious</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> monk he stroked them time and again; and unstrung them, scattered</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> them, and restrung them in different orders until a layer of each</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> had been worn away.\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Historical Context</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">The prayer bead / monk metaphor carries deep roots in Buddhist and</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Hindu traditions — </span><span style=\"color:#E1E4E8;font-style:italic\">*japa mala*</span><span style=\"color:#E1E4E8\"> beads used for meditation have been</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">part of spiritual practice for thousands of years. In the Chinese</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">context, Buddhism, Taoism, and folk spirituality often blended</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">together, so a Chinese sci-fi reader would likely feel this resonance</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">immediately. The image of a monk mindfully repeating prayers until</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">the beads literally wear smooth is a real phenomenon — in some</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">traditions, monks are said to 磨损 (wó sǔn) their beads through</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">decades of devotion.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Liu himself was an engineering student in North China during the</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Cultural Revolution, so the physicality of repeated labor — the way</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">objects get worn, transformed, made sacred through use — might</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">reflect a materialist worldview that nevertheless romanticizes</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">discipline.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Pop Culture &#x26; Internet</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">The idea of \"wearing down\" meaning through repetition has a modern</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">parallel in the way certain ideas on Reddit's r/philosophy or YouTube</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">essay channels get analyzed until they're drained. There's also a</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">strong resonance with video essay culture — creators who return to the</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">same passages repeatedly until new meaning emerges. The passage itself</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">feels almost like a description of what a great video essay does to a</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">piece of media.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Books &#x26; Literature</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">This is almost a meta-description of close reading itself. The</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">literary theorist Hans-Georg Gadamer wrote about the \"fusion of</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">horizons\" — the way understanding deepens through repeated dialogue</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">between reader and text. The prayer bead image also echoes Umberto</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Eco's concept of the </span><span style=\"color:#E1E4E8;font-style:italic\">*open text*</span><span style=\"color:#E1E4E8\">, which rewards repeated visits. In</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">a Chinese literary tradition, this mirrors the scholar's relationship</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">with the Confucian classics — the </span><span style=\"color:#E1E4E8;font-style:italic\">*Four Books*</span><span style=\"color:#E1E4E8\"> that Ming Dynasty</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">scholars would return to compulsively, each reading layering new</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">meaning.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Unexpected Trivia</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">The Chinese word for \"ruminate\" (咀嚼, jǔjué) literally means \"chew\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">— as in chewing food. The passage's \"chewing over every word\" is</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">even more visceral in the original imagery. Also: in some Buddhist</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">monasteries, monks recount that the holes in prayer beads eventually</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">become polished to a mirror shine from skin oils and decades of</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">handling.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Emotional / Thematic Insight</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">This passage is almost a thesis statement for the entire *Remembrance</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">of Earth's Past* trilogy — the Trisolaris series grapples with</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">communication across cosmic time scales, with meaning that gets worn</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">down, reinterpreted, and restrung by civilizations that may not even</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">share the same physics. The Dark Forest itself is a theory about how</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">civilizations communicate (or don't) — each \"wearing down\" the other's</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">signals until something alien emerges. Liu is telling you how to read</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">his books: like a monk, slowly, repeatedly, until meaning is absorbed</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">into the self.</span></span></code></pre>\n<p>Simple, repeatable, always in the same place.</p>\n<h2>What I'm Still Figuring Out</h2>\n<p>Whether to write my own reactions alongside the enrichment, or let the enrichment stand alone. Right now it's enrichment only — but I think the most valuable version is when I also note what the passage made me think about. Still developing that.</p>\n<p>Also: when to stop. Some passages probably don't need enriching. I'm figuring out what \"complex, deep, or interesting\" actually means to me in practice.</p>\n<h2>Why I Created This</h2>\n<p>Most AI book tools either summarize chapters (which I don't need — I just read the chapter) or generate flashcards (which feels like studying, not reading). I'm not trying to memorize more or pass quizzes. I'm trying to actually engage with what I read, make it richer and weirder and more likely to stick.</p>\n<p>What I want from this long-term: a reading log that's actually useful six months from now, connections between books I wouldn't catch on my own, and a reading experience that feels less lonely. Eventually I'd love a map of my intellectual influences across whatever I'm reading.</p>\n<p>Not trying to replace reading. Trying to make it more like the conversations I wish I could have about every book I open.</p>",
            "url": "https://lukemanning.ie/blog/building-a-reading-companion-for-my-vault",
            "title": "Building a Reading Companion for My Vault",
            "summary": "<p>I read a lot. Not in a flex way but more like a borderline problem. The problem is none of it sticks. I close the book, move on, and six months later I remember almost nothing. Not the ideas, not the context, barely even the thesis.</p>\n<p>My vault lives on Syncthing now — <a href=\"/blog/syncthing-obsidian-vault-sync\">I moved away from Obsidian Sync to cut the cost</a>. Having my vault synced across machines without a subscription made it more viable as a daily tool.</p>\n<p>I tried highlighting. I tried taking notes on my phone while reading. Neither worked for me. Highlights become a graveyard of yellow that I never revisit, and notes are too spontaneous to build anything coherent.</p>\n<p>What I wanted was a companion. Someone to actually <em>discuss</em> what I'm reading with. Not summarize the book back at me, but pull it apart, connect it to weird stuff, make it memorable.</p>\n<p>So I built one. It runs as a skill in my Hermes Agent setup and the enriched data gets stored in my Obsidian Vault.</p>\n<h2>What It Does</h2>\n<p>The reading companion is a skill I can trigger whenever I'm reading something that hits me as interesting, complex, or just worth sitting with. I paste a passage, and it comes back enriched across five areas:</p>\n<ol>\n<li>Historical context — what was happening when this was written, who the author was influenced by, related events</li>\n<li>Pop culture and internet references — viral moments, memes, Reddit threads, movies that tangentially connect</li>\n<li>Books and literature — other works that echo the same ideas</li>\n<li>Unexpected trivia — science, psychology, language, whatever weirdly relevant fact surfaced</li>\n<li>Emotional and thematic insight — what the passage is actually <em>about</em> underneath the words</li>\n</ol>\n<p>The goal isn't a book report. It's more like having a smart friend who makes unexpected connections and gets genuinely excited about ideas.</p>\n<h2>Integration With the Vault</h2>\n<p>This is where it clicked for me. The enriched passages go straight into <code>~/vault/books/&#x3C;book-slug>.md</code>. One note per book, entries appended as I read.</p>\n<p>So instead of highlights scattered across a Kindle or margins that never talk to each other, I have a running document that traces my journey through a single book. Each entry has the passage, the context, and the date. When I finish the book, I have a map of every moment that made me stop and think.</p>\n<p>The vault schema already had a slot for this. The reading-companion skill fills it. Here's an example from <em>The Dark Forest</em> by Liu Cixin:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Passage — Chapter 9 (2025-04-13)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> \"He had returned countless times to these words, analyzing each</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> sentence from every angle and chewing over every word. The component</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> words had been strung into a set of prayer beads, and like a pious</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> monk he stroked them time and again; and unstrung them, scattered</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> them, and restrung them in different orders until a layer of each</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">> had been worn away.\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Historical Context</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">The prayer bead / monk metaphor carries deep roots in Buddhist and</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Hindu traditions — </span><span style=\"color:#E1E4E8;font-style:italic\">*japa mala*</span><span style=\"color:#E1E4E8\"> beads used for meditation have been</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">part of spiritual practice for thousands of years. In the Chinese</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">context, Buddhism, Taoism, and folk spirituality often blended</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">together, so a Chinese sci-fi reader would likely feel this resonance</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">immediately. The image of a monk mindfully repeating prayers until</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">the beads literally wear smooth is a real phenomenon — in some</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">traditions, monks are said to 磨损 (wó sǔn) their beads through</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">decades of devotion.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Liu himself was an engineering student in North China during the</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Cultural Revolution, so the physicality of repeated labor — the way</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">objects get worn, transformed, made sacred through use — might</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">reflect a materialist worldview that nevertheless romanticizes</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">discipline.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Pop Culture &#x26; Internet</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">The idea of \"wearing down\" meaning through repetition has a modern</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">parallel in the way certain ideas on Reddit's r/philosophy or YouTube</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">essay channels get analyzed until they're drained. There's also a</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">strong resonance with video essay culture — creators who return to the</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">same passages repeatedly until new meaning emerges. The passage itself</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">feels almost like a description of what a great video essay does to a</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">piece of media.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Books &#x26; Literature</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">This is almost a meta-description of close reading itself. The</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">literary theorist Hans-Georg Gadamer wrote about the \"fusion of</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">horizons\" — the way understanding deepens through repeated dialogue</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">between reader and text. The prayer bead image also echoes Umberto</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Eco's concept of the </span><span style=\"color:#E1E4E8;font-style:italic\">*open text*</span><span style=\"color:#E1E4E8\">, which rewards repeated visits. In</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">a Chinese literary tradition, this mirrors the scholar's relationship</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">with the Confucian classics — the </span><span style=\"color:#E1E4E8;font-style:italic\">*Four Books*</span><span style=\"color:#E1E4E8\"> that Ming Dynasty</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">scholars would return to compulsively, each reading layering new</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">meaning.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Unexpected Trivia</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">The Chinese word for \"ruminate\" (咀嚼, jǔjué) literally means \"chew\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">— as in chewing food. The passage's \"chewing over every word\" is</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">even more visceral in the original imagery. Also: in some Buddhist</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">monasteries, monks recount that the holes in prayer beads eventually</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">become polished to a mirror shine from skin oils and decades of</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">handling.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Emotional / Thematic Insight</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">This passage is almost a thesis statement for the entire *Remembrance</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">of Earth's Past* trilogy — the Trisolaris series grapples with</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">communication across cosmic time scales, with meaning that gets worn</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">down, reinterpreted, and restrung by civilizations that may not even</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">share the same physics. The Dark Forest itself is a theory about how</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">civilizations communicate (or don't) — each \"wearing down\" the other's</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">signals until something alien emerges. Liu is telling you how to read</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">his books: like a monk, slowly, repeatedly, until meaning is absorbed</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">into the self.</span></span></code></pre>\n<p>Simple, repeatable, always in the same place.</p>\n<h2>What I'm Still Figuring Out</h2>\n<p>Whether to write my own reactions alongside the enrichment, or let the enrichment stand alone. Right now it's enrichment only — but I think the most valuable version is when I also note what the passage made me think about. Still developing that.</p>\n<p>Also: when to stop. Some passages probably don't need enriching. I'm figuring out what \"complex, deep, or interesting\" actually means to me in practice.</p>\n<h2>Why I Created This</h2>\n<p>Most AI book tools either summarize chapters (which I don't need — I just read the chapter) or generate flashcards (which feels like studying, not reading). I'm not trying to memorize more or pass quizzes. I'm trying to actually engage with what I read, make it richer and weirder and more likely to stick.</p>\n<p>What I want from this long-term: a reading log that's actually useful six months from now, connections between books I wouldn't catch on my own, and a reading experience that feels less lonely. Eventually I'd love a map of my intellectual influences across whatever I'm reading.</p>\n<p>Not trying to replace reading. Trying to make it more like the conversations I wish I could have about every book I open.</p>",
            "date_modified": "2026-04-06T00:00:00.000Z",
            "tags": [
                "hermes"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/the-day-i-achieved-nothing",
            "content_html": "<p>There are days where you finish work and you genuinely cannot point to a single thing you did.</p>\n<p>Not because you were lazy. Not because you weren't trying. But because from the moment the day started, it belonged to everyone else.</p>\n<p>I had one of those days recently. Back-to-back calls. Conversations pulled from every direction. Lunch eaten while staring at a Zoom screen. By 5pm I was completely drained, and yet felt like I had nothing to show for it. No output. No visible progress. Just a long list of other people's problems that I'd briefly helped with and moved on from.</p>\n<p>It felt like I hadn't worked at all. It felt like I hadn't <em>existed</em> at all, in any meaningful way for my own work.</p>\n<hr>\n<p>Nobody really prepares you for this part of a senior role. A big chunk of what I do now is invisible.</p>\n<p>I'm not closing tickets anymore. I'm not on the front line. I'm the person who gets pulled into a Slack thread to help an engineer who's stuck, then drops into an escalation call to figure out next steps for a customer, then reviews a KB article someone's asked me to look at, then gets tapped on the shoulder at my desk because someone needs a hand. The impact is real. People are unblocked, things are moving. But I can't point to any of it and say \"I built that.\"</p>\n<p>That's fine. That's part of the role. I accepted that.</p>\n<p>What I didn't fully accept until recently is the cost of operating entirely in reactive mode. When my whole day is defined by incoming demands, other people's priorities, other people's timelines, other people's questions, I never get to think. And thinking is where the actual leverage lives.</p>\n<p>Not answering questions. Thinking.</p>\n<hr>\n<p>The worst part of a fully reactive day isn't the exhaustion. It's the feeling that my time wasn't mine.</p>\n<p>I noticed it building up over weeks. Small stuff at first — catching myself checking Slack before I'd even opened my laptop in the morning. Then bigger stuff. I realised I hadn't worked on anything I'd chosen to work on for over a month. The frustration was quiet but constant, like a low hum I'd stopped noticing because it was always there.</p>\n<p>I started wondering what it would take to protect against days like that. Not eliminate them — some reactive days are just part of the job. But I wanted a counterweight. Something structural. I thought about blocking out mornings, or setting \"no meeting\" days, but those always seemed to erode the moment something came up. What I actually needed was something harder to break.</p>\n<hr>\n<p>The idea I landed on is simple: dedicated growth time. One or two days per month, fully protected. No calls. No Slack. No desk visits. Unreachable. And at the end of it, something to show — a prototype, a write-up, a working tool, something real.</p>\n<p>Not a meeting to discuss doing a thing. The thing. Delivered.</p>\n<p>The key word is <em>unreachable</em>. Not \"I'll try to be heads down.\" Fully off the grid for the day. Because the moment there's an exception — <em>just this once, there's something urgent</em> — the whole concept collapses. The value of protected time comes entirely from the protection being real.</p>\n<p>I'm going to run this as a personal experiment first. Document what I work on. What I actually produce. How it feels going back into a normal week afterward. Then see if it's worth bringing to others in a similar position.</p>\n<p>This also connects to <a href=\"/blog/same-content-different-job\">why compound content beats isolated posts</a> — if you're not building with intentionality, the work just piles up instead of compounding.</p>\n<hr>\n<p>The reactive day will still happen. That's not going away.</p>\n<p>But it doesn't have to be the only kind of day. The thinking, building, creating side of work needs protected space too. Not as a break. Because that's where the actual progress comes from.</p>\n<p>The work that moves things forward rarely happens in a Zoom call. It happens in the quiet.</p>",
            "url": "https://lukemanning.ie/blog/the-day-i-achieved-nothing",
            "title": "The Day I Achieved Nothing",
            "summary": "<p>There are days where you finish work and you genuinely cannot point to a single thing you did.</p>\n<p>Not because you were lazy. Not because you weren't trying. But because from the moment the day started, it belonged to everyone else.</p>\n<p>I had one of those days recently. Back-to-back calls. Conversations pulled from every direction. Lunch eaten while staring at a Zoom screen. By 5pm I was completely drained, and yet felt like I had nothing to show for it. No output. No visible progress. Just a long list of other people's problems that I'd briefly helped with and moved on from.</p>\n<p>It felt like I hadn't worked at all. It felt like I hadn't <em>existed</em> at all, in any meaningful way for my own work.</p>\n<hr>\n<p>Nobody really prepares you for this part of a senior role. A big chunk of what I do now is invisible.</p>\n<p>I'm not closing tickets anymore. I'm not on the front line. I'm the person who gets pulled into a Slack thread to help an engineer who's stuck, then drops into an escalation call to figure out next steps for a customer, then reviews a KB article someone's asked me to look at, then gets tapped on the shoulder at my desk because someone needs a hand. The impact is real. People are unblocked, things are moving. But I can't point to any of it and say \"I built that.\"</p>\n<p>That's fine. That's part of the role. I accepted that.</p>\n<p>What I didn't fully accept until recently is the cost of operating entirely in reactive mode. When my whole day is defined by incoming demands, other people's priorities, other people's timelines, other people's questions, I never get to think. And thinking is where the actual leverage lives.</p>\n<p>Not answering questions. Thinking.</p>\n<hr>\n<p>The worst part of a fully reactive day isn't the exhaustion. It's the feeling that my time wasn't mine.</p>\n<p>I noticed it building up over weeks. Small stuff at first — catching myself checking Slack before I'd even opened my laptop in the morning. Then bigger stuff. I realised I hadn't worked on anything I'd chosen to work on for over a month. The frustration was quiet but constant, like a low hum I'd stopped noticing because it was always there.</p>\n<p>I started wondering what it would take to protect against days like that. Not eliminate them — some reactive days are just part of the job. But I wanted a counterweight. Something structural. I thought about blocking out mornings, or setting \"no meeting\" days, but those always seemed to erode the moment something came up. What I actually needed was something harder to break.</p>\n<hr>\n<p>The idea I landed on is simple: dedicated growth time. One or two days per month, fully protected. No calls. No Slack. No desk visits. Unreachable. And at the end of it, something to show — a prototype, a write-up, a working tool, something real.</p>\n<p>Not a meeting to discuss doing a thing. The thing. Delivered.</p>\n<p>The key word is <em>unreachable</em>. Not \"I'll try to be heads down.\" Fully off the grid for the day. Because the moment there's an exception — <em>just this once, there's something urgent</em> — the whole concept collapses. The value of protected time comes entirely from the protection being real.</p>\n<p>I'm going to run this as a personal experiment first. Document what I work on. What I actually produce. How it feels going back into a normal week afterward. Then see if it's worth bringing to others in a similar position.</p>\n<p>This also connects to <a href=\"/blog/same-content-different-job\">why compound content beats isolated posts</a> — if you're not building with intentionality, the work just piles up instead of compounding.</p>\n<hr>\n<p>The reactive day will still happen. That's not going away.</p>\n<p>But it doesn't have to be the only kind of day. The thinking, building, creating side of work needs protected space too. Not as a break. Because that's where the actual progress comes from.</p>\n<p>The work that moves things forward rarely happens in a Zoom call. It happens in the quiet.</p>",
            "date_modified": "2026-04-05T00:00:00.000Z",
            "tags": [
                "reflections"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/posting-into-the-void",
            "content_html": "<p>I spent the last few weeks and months working on my personal site. I shipped Projex — a shadcn-style component library for developer portfolio pages — posted about it on X to silence, posted about it on Reddit to near-silence, and sat with that feeling for a while today.</p>\n<p>It doesn't feel great.</p>\n<hr>\n<h2>The Specific Kind of Demoralizing</h2>\n<p>It's not burnout exactly. I'm not tired of building. I enjoy it. This has become a hobby, and I mean that genuinely, not as a cope, but because the time I spend building doesn't feel wasted even when nothing comes of it.</p>\n<p>What stings is the silence after you share something.</p>\n<p>You build a thing. You think it's useful. You give it away for free. You're not asking for money, you're not running ads, you're not trying to acquire users in some predatory growth-hack way. You just want to know someone tried it. Maybe filed an issue. Maybe said \"this is neat.\" That's it.</p>\n<p>Instead: nothing.</p>\n<hr>\n<h2>Reddit Is Broken For This</h2>\n<p>Reddit right now is flooded with AI-generated spam and people shilling SaaS products that barely work. I hate that. And I hate that when I go to post about something I genuinely built and care about, I feel like one of them.</p>\n<p>That's the uncomfortable part. I'm not shilling. Projex is free. I'm not asking for anything. But the act of posting about your own work in that environment feels gross now, because the context is so poisoned.</p>\n<p>I don't want to learn marketing. I don't want to become someone who optimises posts for engagement. My time is genuinely better spent getting better at building. That's where I am right now.</p>\n<hr>\n<h2>X Isn't Working Either</h2>\n<p>I don't like X. Posting there feels like talking into a void. Maybe that changes with audience size (probably does), but right now it's not where I want to spend energy.</p>\n<p>I was hoping Projex might get a small handful of users. Not thousands. Not virality. Just a handful of developers who found it useful and maybe said so. That hasn't happened yet, and that's what knocked me sideways a bit today.</p>\n<hr>\n<h2>DevBreakdown and The Fuzzy Middle</h2>\n<p>I've also been stalling on DevBreakdown — my AI model and subscription comparison site. Partly because the affiliate angle I originally had in mind is basically a non-starter now. Partly because the space moves so fast I can hardly keep up with it myself, let alone maintain a site tracking it.</p>\n<p>And then there's the motivation problem: if I'm earning nothing from it, and it's hard to keep current, and I'm already feeling the silence from Projex... why start another thing that shouts into the same void? <a href=\"/blog/the-entrepreneurial-conundrum\">The entrepreneurial conundrum</a> is familiar territory here — the gap between having ideas and actually executing on them.</p>\n<p>I don't have a clean answer to that. What I do know is that the clarity problem is real. I know what I want DB to be, but the steps between \"start\" and \"ship something useful\" are fuzzy. That fuzziness is what kills momentum before you even begin.</p>\n<hr>\n<h2>Why I'm Still Going</h2>\n<p>I kept coming back to something today. I've shipped more in the last few months than most people who <em>talk</em> about building ever do.</p>\n<p>That's not a flex. It's just what I noticed. And it made me realise: component libraries don't go viral. They get quietly discovered by developers who need them. npm downloads tick up without fanfare. Someone uses your thing without ever telling you.</p>\n<p>Maybe that's already happening with Projex. I genuinely don't know.</p>\n<p>I know this is a long game. Knowing that doesn't make today any less shit. But my real goal was never a Reddit post going off. It's building a portfolio of work that compounds, covering my AI subs with something I made, and eventually having enough momentum that the audience finds me rather than me chasing it.</p>\n<p>€100/month net neutral would genuinely feel like a win right now. That's not a low bar out of pessimism. It's a concrete, honest first milestone.</p>\n<hr>\n<p>Today I'm going back to the Straico API proxy I half-built a while ago. It's broken in ways I want to fix. It's contained. It's mine. And finishing it will feel like a win without needing anyone to validate it.</p>\n<p>That's enough for today.</p>\n<p>There's something underneath all of this I keep coming back to: <a href=\"/blog/fluorescent-office-essay\">the essay making the rounds about not following your passion</a> makes a case for accepting where you are. But I think there's a version of that argument that quietly recommends giving up on building anything outside the office. That's the part I can't get on board with.</p>",
            "url": "https://lukemanning.ie/blog/posting-into-the-void",
            "title": "Posting Into The Void",
            "summary": "<p>I spent the last few weeks and months working on my personal site. I shipped Projex — a shadcn-style component library for developer portfolio pages — posted about it on X to silence, posted about it on Reddit to near-silence, and sat with that feeling for a while today.</p>\n<p>It doesn't feel great.</p>\n<hr>\n<h2>The Specific Kind of Demoralizing</h2>\n<p>It's not burnout exactly. I'm not tired of building. I enjoy it. This has become a hobby, and I mean that genuinely, not as a cope, but because the time I spend building doesn't feel wasted even when nothing comes of it.</p>\n<p>What stings is the silence after you share something.</p>\n<p>You build a thing. You think it's useful. You give it away for free. You're not asking for money, you're not running ads, you're not trying to acquire users in some predatory growth-hack way. You just want to know someone tried it. Maybe filed an issue. Maybe said \"this is neat.\" That's it.</p>\n<p>Instead: nothing.</p>\n<hr>\n<h2>Reddit Is Broken For This</h2>\n<p>Reddit right now is flooded with AI-generated spam and people shilling SaaS products that barely work. I hate that. And I hate that when I go to post about something I genuinely built and care about, I feel like one of them.</p>\n<p>That's the uncomfortable part. I'm not shilling. Projex is free. I'm not asking for anything. But the act of posting about your own work in that environment feels gross now, because the context is so poisoned.</p>\n<p>I don't want to learn marketing. I don't want to become someone who optimises posts for engagement. My time is genuinely better spent getting better at building. That's where I am right now.</p>\n<hr>\n<h2>X Isn't Working Either</h2>\n<p>I don't like X. Posting there feels like talking into a void. Maybe that changes with audience size (probably does), but right now it's not where I want to spend energy.</p>\n<p>I was hoping Projex might get a small handful of users. Not thousands. Not virality. Just a handful of developers who found it useful and maybe said so. That hasn't happened yet, and that's what knocked me sideways a bit today.</p>\n<hr>\n<h2>DevBreakdown and The Fuzzy Middle</h2>\n<p>I've also been stalling on DevBreakdown — my AI model and subscription comparison site. Partly because the affiliate angle I originally had in mind is basically a non-starter now. Partly because the space moves so fast I can hardly keep up with it myself, let alone maintain a site tracking it.</p>\n<p>And then there's the motivation problem: if I'm earning nothing from it, and it's hard to keep current, and I'm already feeling the silence from Projex... why start another thing that shouts into the same void? <a href=\"/blog/the-entrepreneurial-conundrum\">The entrepreneurial conundrum</a> is familiar territory here — the gap between having ideas and actually executing on them.</p>\n<p>I don't have a clean answer to that. What I do know is that the clarity problem is real. I know what I want DB to be, but the steps between \"start\" and \"ship something useful\" are fuzzy. That fuzziness is what kills momentum before you even begin.</p>\n<hr>\n<h2>Why I'm Still Going</h2>\n<p>I kept coming back to something today. I've shipped more in the last few months than most people who <em>talk</em> about building ever do.</p>\n<p>That's not a flex. It's just what I noticed. And it made me realise: component libraries don't go viral. They get quietly discovered by developers who need them. npm downloads tick up without fanfare. Someone uses your thing without ever telling you.</p>\n<p>Maybe that's already happening with Projex. I genuinely don't know.</p>\n<p>I know this is a long game. Knowing that doesn't make today any less shit. But my real goal was never a Reddit post going off. It's building a portfolio of work that compounds, covering my AI subs with something I made, and eventually having enough momentum that the audience finds me rather than me chasing it.</p>\n<p>€100/month net neutral would genuinely feel like a win right now. That's not a low bar out of pessimism. It's a concrete, honest first milestone.</p>\n<hr>\n<p>Today I'm going back to the Straico API proxy I half-built a while ago. It's broken in ways I want to fix. It's contained. It's mine. And finishing it will feel like a win without needing anyone to validate it.</p>\n<p>That's enough for today.</p>\n<p>There's something underneath all of this I keep coming back to: <a href=\"/blog/fluorescent-office-essay\">the essay making the rounds about not following your passion</a> makes a case for accepting where you are. But I think there's a version of that argument that quietly recommends giving up on building anything outside the office. That's the part I can't get on board with.</p>",
            "date_modified": "2026-04-04T00:00:00.000Z",
            "tags": [
                "projex",
                "reflections"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/anthropic-open-source-walled-garden-clawdbot-opencode",
            "content_html": "<p>I was using Claude Code daily. I built <a href=\"/blog/building-multi-agent-blog-review-system\">my entire blog review system</a> with it. I hit <a href=\"/blog/premature-optimization-multi-agent-prompts\">session limits constantly</a> and tried to optimise around them. Now I use OpenCode, an open-source agent that works with any provider.</p>\n<p>Then one day (maybe a month or two ago) I pulled the latest OpenCode version and Claude subscription support was just gone. Not deprecated. Not moved to a config flag. Removed entirely, because Anthropic demanded it. On top of that, I kept hitting <a href=\"https://github.com/anthropics/claude-code/issues/16157\">usage limits on my Pro subscription</a> despite paying $20/month. That issue was opened for Max subscribers but people on every tier were hitting the same thing.</p>\n<p>I cancelled my Anthropic subscription. I use GLM-4.7 and GLM-5.1 now through OpenCode with a GLM coding subscription. Considering adding a GitHub Copilot subscription so I can still use Anthropic models through OpenCode's API integration. I still use Claude on the web and on my phone. The model is good. The repeated session limit issues and the company's behavior around open source tools is what pushed me away.</p>\n<h2>The OpenClaw Story</h2>\n<p>I'd already been watching Anthropic's relationship with open source tools for a while. Then I came across the OpenClaw story. An open source project that got forced to rename because it had \"Clawd\" in the name. That sent me down a rabbit hole.</p>\n<p>Peter Steinberger (steipete on GitHub) built a personal AI assistant. Originally called Warelay. MIT licensed, supports 20+ messaging channels, and has 343k GitHub stars (yes, really). A legit open source project by any measure. The fastest growing in history in terms of stars.</p>\n<p>On January 4, 2026, he renamed it to ClawdBot. Makes sense. It's a bot that uses Claude. Clear, descriptive name. A play on the fact it used Claude without using the name Claude.</p>\n<p>Three weeks later, he got a letter from Anthropic's legal team.</p>\n<p>The project was renamed to \"Moltbot\" on January 27, 2026. The commit message read: \"refactor: rename clawdbot to moltbot with legacy compat.\" The maintainer confirmed in <a href=\"https://github.com/openclaw/openclaw/issues/2825\">GitHub issue #2825</a>: \"Moltbot is the official name now. Clawdbot has been letter sent by Anthropic.\"</p>\n<p>That name lasted three days. On January 30, another commit: \"refactor: rename to openclaw.\" The project has been OpenClaw ever since.</p>\n<p>An open source project with 343k stars, MIT licensed, built by an independent developer, got a trademark letter because its name contained \"Clawd.\" Yeah, obviously a play on \"Claude,\" but still not \"Claude.\"</p>\n<p>And that wasn't all. Anthropic also reportedly changed their API to reject any request whose system prompt contains the phrase \"Open Claw.\" Not a rate limit, not a warning, but a hard block. (This was reported by Theo — he runs t3.gg, covers AI and dev tools — I haven't tested it myself.) If that's accurate, it goes way beyond trademark enforcement. That would be technical suppression of a specific open source project.</p>\n<h2>What Happened to OpenCode</h2>\n<p>The OpenCode situation is different but follows the same pattern.</p>\n<p>For context: OpenCode works with multiple AI providers. You configure it with credentials for whichever provider you want, and it routes requests to your chosen model. Before all this, Claude subscription access was one of those options. You could use your existing Pro or Max subscription through OpenCode instead of being locked into Anthropic's client.</p>\n<p>Anthropic added checks to stop third-party tools from impersonating the Claude Code client. This broke people's ability to use their Claude subscriptions through OpenCode, Cursor, and any other third-party harness.</p>\n<p>Then they updated their terms of service to explicitly ban using consumer subscriptions (Pro/Max plans) as authentication for third-party tools. So even people paying Anthropic $20/month for Pro can't legally use that subscription through OpenCode. You have to use the Anthropic harness (Claude Code).</p>\n<p>OpenCode had its own PR that removed Claude subscription integration entirely, with \"Anthropic legal requests\" cited as the reason. Not a technical limitation. A legal demand.</p>\n<p>I get protecting your trademark. I get not wanting people to think an unofficial tool is officially affiliated with you. But the OpenCode situation isn't about trademark confusion. It's about controlling which tools can access Claude's API and how.</p>\n<p>OpenCode wasn't pretending to be Claude Code. It was using Claude as a provider, the same way it uses ChatGPT or any other model. Anthropic decided they didn't want subscription auth flowing through third-party tools, and they had the legal muscle to enforce it. Their stated justification was something about third-party harnesses interfering with their analytics and causing unusual traffic patterns. At least, that's what I gathered. It's hard to know how much of that is genuine concern versus justifying the walled garden.</p>\n<p>The rules around what's allowed are vague enough that even people trying to play by them can't get straight answers. Matt Pocock — whose TypeScript stuff I've used for a while — spent over a month trying to get Anthropic to confirm whether his paid Claude Code course was allowed to exist. Their response: \"We're working on it.\" Repeatedly. When pressed for an ETA, same answer. Theo has called this out too — he believes Anthropic keeps the terms vague intentionally so they can move the goalposts later. That tracks with what I've seen.</p>\n<h2>The Pattern</h2>\n<p>Anthropic isn't just another company locking everything down. They built MCP (Model Context Protocol) — the protocol that lets OpenCode talk to external tools. And then gave it away. Fully open source. Anthropic doesn't control it. They offer free Claude Max access to open source maintainers. Their output terms are solid; you own what Claude generates.</p>\n<p>But then Claude Code has a public GitHub repo with zero source code. Subscription access is tightly controlled — you can't use what you're paying for through a third-party tool. And their legal team sends trademark letters to open source projects with 343k stars.</p>\n<p>Then there's the distillation thing.</p>\n<p>I read Anthropic's report accusing DeepSeek, Moonshot, and MiniMax of \"distillation attacks\" against Claude and something felt off. They claimed 24,000 fraudulent accounts and 16+ million exchanges. Wrapped it in national security framing stating that distilled models could be used for bioweapons, etc. And this isn't the first time — Anthropic previously accused Windsurf, X AI, and OpenAI of distillation and, from what I've read, was wrong each time.</p>\n<p>The distillation accusations fit the same pattern. Anthropic claims others are exploiting Claude's openness, uses that to justify tighter restrictions, and those restrictions happen to protect their revenue. Could be genuine security concern. Could be strategic. But it's the same thing I keep seeing.</p>\n<p>The only major lab that has released zero open-weight models is also the one arguing most loudly that open-weight models are dangerous. I don't think that's a coincidence.</p>\n<p>I initially thought the ClawdBot thing was just standard trademark enforcement. But then I kept looking. I'm not sure if this is a fair reading, honestly. I keep going back and forth on it. But the pattern I keep seeing is decisions being made to be open where it benefits Anthropic, and closed where it protects their revenue.</p>\n<p>That's absolutely a valid business strategy. It's just not what \"open\" means. Especially when most of their competitors are making strides to become more open.</p>\n<h2>Where I'm At</h2>\n<p>I'm going to keep using OpenCode for now.</p>\n<p>I might be wrong about the intent. Maybe Anthropic has good reasons for each individual decision. The ClawdBot rename could be standard trademark enforcement. The OpenCode crackdown could be about subscription terms or it could legitimately be about analytical data being interfered with by third party harnesses.</p>\n<p>But when I look at the pattern, it looks like a company building a walled garden around Claude while also contributing genuinely open infrastructure. I don't have a clean conclusion here. I'm still forming my thinking on this. But I know that my workflow got disrupted, an open source project got renamed three times in a month, and the company responsible for both is the same one that open-sourced MCP.</p>\n<p>I just don't understand their actions sometimes.</p>",
            "url": "https://lukemanning.ie/blog/anthropic-open-source-walled-garden-clawdbot-opencode",
            "title": "Anthropic, Open Source, and Why I Cancelled My Subscription",
            "summary": "<p>I was using Claude Code daily. I built <a href=\"/blog/building-multi-agent-blog-review-system\">my entire blog review system</a> with it. I hit <a href=\"/blog/premature-optimization-multi-agent-prompts\">session limits constantly</a> and tried to optimise around them. Now I use OpenCode, an open-source agent that works with any provider.</p>\n<p>Then one day (maybe a month or two ago) I pulled the latest OpenCode version and Claude subscription support was just gone. Not deprecated. Not moved to a config flag. Removed entirely, because Anthropic demanded it. On top of that, I kept hitting <a href=\"https://github.com/anthropics/claude-code/issues/16157\">usage limits on my Pro subscription</a> despite paying $20/month. That issue was opened for Max subscribers but people on every tier were hitting the same thing.</p>\n<p>I cancelled my Anthropic subscription. I use GLM-4.7 and GLM-5.1 now through OpenCode with a GLM coding subscription. Considering adding a GitHub Copilot subscription so I can still use Anthropic models through OpenCode's API integration. I still use Claude on the web and on my phone. The model is good. The repeated session limit issues and the company's behavior around open source tools is what pushed me away.</p>\n<h2>The OpenClaw Story</h2>\n<p>I'd already been watching Anthropic's relationship with open source tools for a while. Then I came across the OpenClaw story. An open source project that got forced to rename because it had \"Clawd\" in the name. That sent me down a rabbit hole.</p>\n<p>Peter Steinberger (steipete on GitHub) built a personal AI assistant. Originally called Warelay. MIT licensed, supports 20+ messaging channels, and has 343k GitHub stars (yes, really). A legit open source project by any measure. The fastest growing in history in terms of stars.</p>\n<p>On January 4, 2026, he renamed it to ClawdBot. Makes sense. It's a bot that uses Claude. Clear, descriptive name. A play on the fact it used Claude without using the name Claude.</p>\n<p>Three weeks later, he got a letter from Anthropic's legal team.</p>\n<p>The project was renamed to \"Moltbot\" on January 27, 2026. The commit message read: \"refactor: rename clawdbot to moltbot with legacy compat.\" The maintainer confirmed in <a href=\"https://github.com/openclaw/openclaw/issues/2825\">GitHub issue #2825</a>: \"Moltbot is the official name now. Clawdbot has been letter sent by Anthropic.\"</p>\n<p>That name lasted three days. On January 30, another commit: \"refactor: rename to openclaw.\" The project has been OpenClaw ever since.</p>\n<p>An open source project with 343k stars, MIT licensed, built by an independent developer, got a trademark letter because its name contained \"Clawd.\" Yeah, obviously a play on \"Claude,\" but still not \"Claude.\"</p>\n<p>And that wasn't all. Anthropic also reportedly changed their API to reject any request whose system prompt contains the phrase \"Open Claw.\" Not a rate limit, not a warning, but a hard block. (This was reported by Theo — he runs t3.gg, covers AI and dev tools — I haven't tested it myself.) If that's accurate, it goes way beyond trademark enforcement. That would be technical suppression of a specific open source project.</p>\n<h2>What Happened to OpenCode</h2>\n<p>The OpenCode situation is different but follows the same pattern.</p>\n<p>For context: OpenCode works with multiple AI providers. You configure it with credentials for whichever provider you want, and it routes requests to your chosen model. Before all this, Claude subscription access was one of those options. You could use your existing Pro or Max subscription through OpenCode instead of being locked into Anthropic's client.</p>\n<p>Anthropic added checks to stop third-party tools from impersonating the Claude Code client. This broke people's ability to use their Claude subscriptions through OpenCode, Cursor, and any other third-party harness.</p>\n<p>Then they updated their terms of service to explicitly ban using consumer subscriptions (Pro/Max plans) as authentication for third-party tools. So even people paying Anthropic $20/month for Pro can't legally use that subscription through OpenCode. You have to use the Anthropic harness (Claude Code).</p>\n<p>OpenCode had its own PR that removed Claude subscription integration entirely, with \"Anthropic legal requests\" cited as the reason. Not a technical limitation. A legal demand.</p>\n<p>I get protecting your trademark. I get not wanting people to think an unofficial tool is officially affiliated with you. But the OpenCode situation isn't about trademark confusion. It's about controlling which tools can access Claude's API and how.</p>\n<p>OpenCode wasn't pretending to be Claude Code. It was using Claude as a provider, the same way it uses ChatGPT or any other model. Anthropic decided they didn't want subscription auth flowing through third-party tools, and they had the legal muscle to enforce it. Their stated justification was something about third-party harnesses interfering with their analytics and causing unusual traffic patterns. At least, that's what I gathered. It's hard to know how much of that is genuine concern versus justifying the walled garden.</p>\n<p>The rules around what's allowed are vague enough that even people trying to play by them can't get straight answers. Matt Pocock — whose TypeScript stuff I've used for a while — spent over a month trying to get Anthropic to confirm whether his paid Claude Code course was allowed to exist. Their response: \"We're working on it.\" Repeatedly. When pressed for an ETA, same answer. Theo has called this out too — he believes Anthropic keeps the terms vague intentionally so they can move the goalposts later. That tracks with what I've seen.</p>\n<h2>The Pattern</h2>\n<p>Anthropic isn't just another company locking everything down. They built MCP (Model Context Protocol) — the protocol that lets OpenCode talk to external tools. And then gave it away. Fully open source. Anthropic doesn't control it. They offer free Claude Max access to open source maintainers. Their output terms are solid; you own what Claude generates.</p>\n<p>But then Claude Code has a public GitHub repo with zero source code. Subscription access is tightly controlled — you can't use what you're paying for through a third-party tool. And their legal team sends trademark letters to open source projects with 343k stars.</p>\n<p>Then there's the distillation thing.</p>\n<p>I read Anthropic's report accusing DeepSeek, Moonshot, and MiniMax of \"distillation attacks\" against Claude and something felt off. They claimed 24,000 fraudulent accounts and 16+ million exchanges. Wrapped it in national security framing stating that distilled models could be used for bioweapons, etc. And this isn't the first time — Anthropic previously accused Windsurf, X AI, and OpenAI of distillation and, from what I've read, was wrong each time.</p>\n<p>The distillation accusations fit the same pattern. Anthropic claims others are exploiting Claude's openness, uses that to justify tighter restrictions, and those restrictions happen to protect their revenue. Could be genuine security concern. Could be strategic. But it's the same thing I keep seeing.</p>\n<p>The only major lab that has released zero open-weight models is also the one arguing most loudly that open-weight models are dangerous. I don't think that's a coincidence.</p>\n<p>I initially thought the ClawdBot thing was just standard trademark enforcement. But then I kept looking. I'm not sure if this is a fair reading, honestly. I keep going back and forth on it. But the pattern I keep seeing is decisions being made to be open where it benefits Anthropic, and closed where it protects their revenue.</p>\n<p>That's absolutely a valid business strategy. It's just not what \"open\" means. Especially when most of their competitors are making strides to become more open.</p>\n<h2>Where I'm At</h2>\n<p>I'm going to keep using OpenCode for now.</p>\n<p>I might be wrong about the intent. Maybe Anthropic has good reasons for each individual decision. The ClawdBot rename could be standard trademark enforcement. The OpenCode crackdown could be about subscription terms or it could legitimately be about analytical data being interfered with by third party harnesses.</p>\n<p>But when I look at the pattern, it looks like a company building a walled garden around Claude while also contributing genuinely open infrastructure. I don't have a clean conclusion here. I'm still forming my thinking on this. But I know that my workflow got disrupted, an open source project got renamed three times in a month, and the company responsible for both is the same one that open-sourced MCP.</p>\n<p>I just don't understand their actions sometimes.</p>",
            "date_modified": "2026-04-03T00:00:00.000Z",
            "tags": [
                "ai"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/why-i-started-this-blog",
            "content_html": "<p>I've started blogs before. Let them die. This one's different. (I hope.)</p>\n<h2>The Home Server Blog</h2>\n<p>Years ago I had a blog about setting up my first home server. It started as personal notes on virtualization, then I thought \"maybe someone else finds this useful\" and put it online. A few posts in, I kept meaning to write the next one. Weeks turned into months. I didn't even notice when the hosting lapsed.</p>\n<p>Some of those posts are still floating around on the Wayback Machine. I keep meaning to dig them up and see if any of it's worth resurrecting. Maybe someday.</p>\n<h2>The Self-Actualisation Blog</h2>\n<p>More recently I started a different blog at lukemanning.name. This one was inspired by a Dan Koe video where he talked about self-actualisation (becoming the most authentic version of yourself, finding meaning and purpose). He said something that stuck with me:</p>\n<blockquote>\n<p>\"Your niche is self-actualisation. What makes you unique are the curiosities, the interests, skills and experience that you gain along the way, because that is unique to everybody.\"</p>\n</blockquote>\n<p>I'd never heard the term before. Went down a rabbit hole reading about Maslow's Hierarchy of Needs, personal growth, the whole thing. Got excited. Started a blog about it.</p>\n<p>That blog lasted a handful of posts. The topic was too broad. \"Personal growth\" is one of those things that sounds meaningful but is hard to write about consistently when you're also working full time and building things. I didn't have a specific enough focus.</p>\n<h2>What Actually Made It Click</h2>\n<p>The thing that finally made this blog happen wasn't a grand realisation about blogging. It was me <a href=\"/blog/setting-up-velite-nextjs-revised\">spending way too long debugging a Velite setup issue, finally getting it working</a>, and then three days later having no idea what I'd done to fix it. I'd closed the terminal, moved on, and the knowledge was just... gone.</p>\n<p>That happened more than once. I'd figure something out, feel smart for about five minutes, then lose the solution somewhere between Slack messages and browser tabs. Writing it down was the obvious fix. I'd just never actually done it consistently.</p>\n<p>The home server blog was actually closer to what I wanted than I realised at the time. Personal notes about specific problems, shared publicly. Not tutorials. Not guides. Just \"here's what broke and here's what I did.\"</p>\n<h2>Will This One Last?</h2>\n<p>I don't know. The home server blog didn't die because the topic was wrong. It died because I stopped making time. That could happen again. I don't have a clever answer for that.</p>\n<p>What I do know is the topic is narrower now. I'm not writing about abstract personal growth. I'm writing about specific technical problems I'm facing day-to-day with specific solutions (or non-solutions). And the raw material is always there. I'm building things every week, and things break every week.</p>\n<p>That's easier to sustain. There's always something breaking.</p>",
            "url": "https://lukemanning.ie/blog/why-i-started-this-blog",
            "title": "Why I Started This Blog",
            "summary": "<p>I've started blogs before. Let them die. This one's different. (I hope.)</p>\n<h2>The Home Server Blog</h2>\n<p>Years ago I had a blog about setting up my first home server. It started as personal notes on virtualization, then I thought \"maybe someone else finds this useful\" and put it online. A few posts in, I kept meaning to write the next one. Weeks turned into months. I didn't even notice when the hosting lapsed.</p>\n<p>Some of those posts are still floating around on the Wayback Machine. I keep meaning to dig them up and see if any of it's worth resurrecting. Maybe someday.</p>\n<h2>The Self-Actualisation Blog</h2>\n<p>More recently I started a different blog at lukemanning.name. This one was inspired by a Dan Koe video where he talked about self-actualisation (becoming the most authentic version of yourself, finding meaning and purpose). He said something that stuck with me:</p>\n<blockquote>\n<p>\"Your niche is self-actualisation. What makes you unique are the curiosities, the interests, skills and experience that you gain along the way, because that is unique to everybody.\"</p>\n</blockquote>\n<p>I'd never heard the term before. Went down a rabbit hole reading about Maslow's Hierarchy of Needs, personal growth, the whole thing. Got excited. Started a blog about it.</p>\n<p>That blog lasted a handful of posts. The topic was too broad. \"Personal growth\" is one of those things that sounds meaningful but is hard to write about consistently when you're also working full time and building things. I didn't have a specific enough focus.</p>\n<h2>What Actually Made It Click</h2>\n<p>The thing that finally made this blog happen wasn't a grand realisation about blogging. It was me <a href=\"/blog/setting-up-velite-nextjs-revised\">spending way too long debugging a Velite setup issue, finally getting it working</a>, and then three days later having no idea what I'd done to fix it. I'd closed the terminal, moved on, and the knowledge was just... gone.</p>\n<p>That happened more than once. I'd figure something out, feel smart for about five minutes, then lose the solution somewhere between Slack messages and browser tabs. Writing it down was the obvious fix. I'd just never actually done it consistently.</p>\n<p>The home server blog was actually closer to what I wanted than I realised at the time. Personal notes about specific problems, shared publicly. Not tutorials. Not guides. Just \"here's what broke and here's what I did.\"</p>\n<h2>Will This One Last?</h2>\n<p>I don't know. The home server blog didn't die because the topic was wrong. It died because I stopped making time. That could happen again. I don't have a clever answer for that.</p>\n<p>What I do know is the topic is narrower now. I'm not writing about abstract personal growth. I'm writing about specific technical problems I'm facing day-to-day with specific solutions (or non-solutions). And the raw material is always there. I'm building things every week, and things break every week.</p>\n<p>That's easier to sustain. There's always something breaking.</p>",
            "date_modified": "2026-04-02T00:00:00.000Z",
            "tags": [
                "reflections"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/opencode-tmux-getting-used-to-keybindings",
            "content_html": "<p>I usually run opencode in a simple WSL terminal window. Somtimes I have multiple instances across different tabs in my Terminal. I came across a video recently on Youtube talking about tmux. I never had a reason to avoid tmux — I just never set it up properly. After watchging the video I was convinced I needed to give it a go, so I decided to actually try running my workflow inside tmux. Turns out that wasn't so straightforward. A series of small issues made the initial attempts quite frustrating.</p>\n<h2>The Escape Key Did Nothing</h2>\n<p>First problem: I hit Esc to interrupt an agent mid-task. Nothing happened. Just sat there. I hit Esc again. Nothing. The agent kept running.</p>\n<p>I'm not sure what I expected tmux to do with Escape, but apparently it intercepts the key for its own bindings and there's a delay before the key reaches the application inside. I wondered if opencode had a bug or just didn't work well with opencode.</p>\n<p>Then I searched \"tmux escape key not working\" and found <code>set -g escape-time 10</code> which reduces that delay. Shorter would be faster but 10ms worked. After that, Escape actually reached opencode.</p>\n<p>But that alone wasn't enough for clipboard to work.</p>\n<h2>Copying Text Was Broken</h2>\n<p>Selecting text in tmux with the mouse didn't copy to my system clipboard. I'd select, paste somewhere else, and get nothing. This is on WSL2 with Ubuntu on Windows — so I have WSLg, which is the Windows layer that lets Linux GUI apps run on Windows 11. But something was intercepting it.</p>\n<p>I tried:</p>\n<ul>\n<li>Checking if WSLg was actually running — it was</li>\n<li>Googled \"tmux clipboard copy not working wsl2 in opencode\" — that's how I found most of the solution</li>\n</ul>\n<p>What actually fixed it was adding these to my <code>~/.tmux.conf</code>:</p>\n<pre><code>set -g escape-time 10\nset -g mouse on\nset -g set-clipboard on\nset -g allow-passthrough on\n</code></pre>\n<p><code>set -g mouse on</code> enables mouse mode so I can select with the mouse at all. <code>set -g set-clipboard on</code> is the key one. It's been around since tmux 2.9 and it handles clipboard integration properly. <code>set -g allow-passthrough on</code> lets applications inside tmux access the system clipboard without tmux intercepting it first. That last one seems particularly relevant for WSL setups, though it might help on other platforms too.</p>\n<p>I'm running tmux 3.4 — From what I've reda these settings should work on tmux 2.9+, which covers most reasonable install paths in 2026.</p>\n<p>To reload after changing tmux.conf, run <code>tmux source-file ~/.tmux.conf</code>.</p>\n<h2>Still Getting Used to It</h2>\n<p>I'm still getting used to the keybindings and functionality inside tmux. I'm still building the muscle memory. The clipboard issue was the biggest blocker, now that works, so the rest is just practice.</p>\n<p>The other opencode posts (<a href=\"/blog/opencode-subagent-permissions-ordering-trap\">permissions trap</a>, <a href=\"/blog/opencode-nested-slash-commands-architecture\">nested commands</a>) don't mention tmux because I wasn't using it. Now I am, and now I know why people talk about these specific settings. It's quite nice having so much flexibility inside a single terminal window.</p>\n<p>I'm genuinely not sure how anyone uses tmux without these settings. Maybe they were just magic configuration I never had.</p>",
            "url": "https://lukemanning.ie/blog/opencode-tmux-getting-used-to-keybindings",
            "title": "Trying to use opencode inside tmux was a struggle",
            "summary": "<p>I usually run opencode in a simple WSL terminal window. Somtimes I have multiple instances across different tabs in my Terminal. I came across a video recently on Youtube talking about tmux. I never had a reason to avoid tmux — I just never set it up properly. After watchging the video I was convinced I needed to give it a go, so I decided to actually try running my workflow inside tmux. Turns out that wasn't so straightforward. A series of small issues made the initial attempts quite frustrating.</p>\n<h2>The Escape Key Did Nothing</h2>\n<p>First problem: I hit Esc to interrupt an agent mid-task. Nothing happened. Just sat there. I hit Esc again. Nothing. The agent kept running.</p>\n<p>I'm not sure what I expected tmux to do with Escape, but apparently it intercepts the key for its own bindings and there's a delay before the key reaches the application inside. I wondered if opencode had a bug or just didn't work well with opencode.</p>\n<p>Then I searched \"tmux escape key not working\" and found <code>set -g escape-time 10</code> which reduces that delay. Shorter would be faster but 10ms worked. After that, Escape actually reached opencode.</p>\n<p>But that alone wasn't enough for clipboard to work.</p>\n<h2>Copying Text Was Broken</h2>\n<p>Selecting text in tmux with the mouse didn't copy to my system clipboard. I'd select, paste somewhere else, and get nothing. This is on WSL2 with Ubuntu on Windows — so I have WSLg, which is the Windows layer that lets Linux GUI apps run on Windows 11. But something was intercepting it.</p>\n<p>I tried:</p>\n<ul>\n<li>Checking if WSLg was actually running — it was</li>\n<li>Googled \"tmux clipboard copy not working wsl2 in opencode\" — that's how I found most of the solution</li>\n</ul>\n<p>What actually fixed it was adding these to my <code>~/.tmux.conf</code>:</p>\n<pre><code>set -g escape-time 10\nset -g mouse on\nset -g set-clipboard on\nset -g allow-passthrough on\n</code></pre>\n<p><code>set -g mouse on</code> enables mouse mode so I can select with the mouse at all. <code>set -g set-clipboard on</code> is the key one. It's been around since tmux 2.9 and it handles clipboard integration properly. <code>set -g allow-passthrough on</code> lets applications inside tmux access the system clipboard without tmux intercepting it first. That last one seems particularly relevant for WSL setups, though it might help on other platforms too.</p>\n<p>I'm running tmux 3.4 — From what I've reda these settings should work on tmux 2.9+, which covers most reasonable install paths in 2026.</p>\n<p>To reload after changing tmux.conf, run <code>tmux source-file ~/.tmux.conf</code>.</p>\n<h2>Still Getting Used to It</h2>\n<p>I'm still getting used to the keybindings and functionality inside tmux. I'm still building the muscle memory. The clipboard issue was the biggest blocker, now that works, so the rest is just practice.</p>\n<p>The other opencode posts (<a href=\"/blog/opencode-subagent-permissions-ordering-trap\">permissions trap</a>, <a href=\"/blog/opencode-nested-slash-commands-architecture\">nested commands</a>) don't mention tmux because I wasn't using it. Now I am, and now I know why people talk about these specific settings. It's quite nice having so much flexibility inside a single terminal window.</p>\n<p>I'm genuinely not sure how anyone uses tmux without these settings. Maybe they were just magic configuration I never had.</p>",
            "date_modified": "2026-04-01T00:00:00.000Z",
            "tags": [
                "opencode"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/fixing-velite-watch-mode-irony",
            "content_html": "<p>I just ran blog review agent on my <a href=\"/blog/adding-syntax-highlighting-shiki\">Adding Syntax Highlighting to Velite with Shiki</a> post. Made a few revisions to content including fixing tutorial framing, added version numbers, corrected CSS classes. Committed everything to git.</p>\n<p>Then I refreshed <a href=\"http://localhost:3000\">http://localhost:3000</a>.</p>\n<p>Still seeing old content. Weird. This never happens.</p>\n<h2>The Confusion</h2>\n<p>Honestly, this annoyed me more than it should have. I had just spent all this time fixing the post, and now I can't even see the changes?</p>\n<p>Velite was supposed to watch files automatically, like some content management systems do. Velite doesn't watch by default - you have to configure it. I'd written about this exact issue in a previous post. The irony was not lost on me.</p>\n<p>So I restarted the dev server, cleared cache, expecting everything to work. The new post showed up, but Velite still wasn't watching files. Changes only appeared when I restarted the server. This definitely is a regression on what I had previously configured.</p>\n<h2>What's Actually Going On?</h2>\n<p>Velite's docs say it supports watch mode, but it needs to be configured properly. The integration with Next.js happens through next.config.ts - that's where Velite gets initialized. If watch mode isn't set up there, Velite just runs once when the server starts and never again.</p>\n<p>I opened <code>next.config.ts</code> at the root of my project. Here's what the file looked like:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#F97583\"> type</span><span style=\"color:#E1E4E8\"> { NextConfig } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'next/config'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> nextConfig</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> NextConfig</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // some other config...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#E1E4E8\"> nextConfig</span></span></code></pre>\n<p>Oh. The Velite integration code was completely missing.</p>\n<p>I found the Velite integration code in their Next.js docs. Here's what should have been in next.config.ts:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#F97583\"> type</span><span style=\"color:#E1E4E8\"> { NextConfig } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'next/config'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> nextConfig</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> NextConfig</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // some other config...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// Velite integration</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isDev</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'development'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isBuild</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> (isDev </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> isBuild)) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> '1'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'velite'</span><span style=\"color:#E1E4E8\">).</span><span style=\"color:#B392F0\">then</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">m</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> m.</span><span style=\"color:#B392F0\">build</span><span style=\"color:#E1E4E8\">({ watch: isDev, clean: </span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isDev }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#E1E4E8\"> nextConfig</span></span></code></pre>\n<p>But there was nothing there. Just Next.js config.</p>\n<p>So that's why watch mode wasn't working. The code to start Velite at all was gone.</p>\n<h2>Why Was It Removed?</h2>\n<p>I checked git history to see when it disappeared. Found commit <code>1afe3e2</code> with message \"Duplicate Velite build issue\" that had removed all the Velite integration code. I figured I must have made a mistake.</p>\n<p>So I restored the code from git. Velite was running again. Watch mode was working. Changes appeared instantly.</p>\n<p>Then I remembered what the duplicate build issue actually was.</p>\n<h2>The Double Build Problem</h2>\n<p>When I deployed to Vercel, I was seeing Velite build twice in the logs:</p>\n<pre><code>[VELITE] building...\n[VELITE] building... again\n</code></pre>\n<p>The logs showed Velite building twice. I still don't know the exact root cause, but what I observed was: Next.js was triggering Velite during its build process, and something else (Vercel's Velite integration?) was also triggering a build. Two builds were happening.</p>\n<p>The original code was running Velite in both development (<code>isDev</code>) and production (<code>isBuild</code>). In production, when <code>isBuild</code> is true, Next.js runs during the build process. If Vercel also has its own Velite integration, both would be triggering Velite builds during deployment. That's inefficient. So commit <code>1afe3e2</code> removed the Velite integration entirely to stop the double builds.</p>\n<p>The problem with that approach? It also broke watch mode in development because the commit removed ALL the integration code.</p>\n<h2>The Actual Fix</h2>\n<p>The real solution is to only run Velite watch mode in development. Vercel handles Velite builds separately in production (I verified this by checking my Vercel project settings - there's a \"Velite\" section that shows it runs builds during deployment), so Next.js shouldn't trigger them.</p>\n<p>Here's what I ended up with:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isDev</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'development'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> isDev) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> '1'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'velite'</span><span style=\"color:#E1E4E8\">).</span><span style=\"color:#B392F0\">then</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">m</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> m.</span><span style=\"color:#B392F0\">build</span><span style=\"color:#E1E4E8\">({ watch: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">, clean: </span><span style=\"color:#79B8FF\">false</span><span style=\"color:#E1E4E8\"> }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>This is different from the original code:</p>\n<ul>\n<li>Removed <code>isBuild</code> - only run in development</li>\n<li>I ended up using <code>watch: true</code> in dev mode</li>\n<li>I ended up using <code>clean: false</code> in dev mode (I tried both settings. With <code>clean: true</code>, Velite would rebuild everything from scratch each time a file changed. With <code>clean: false</code>, it only rebuilt changed files, which was much faster)</li>\n<li>Vercel handles Velite builds separately in production (no watch mode needed)</li>\n</ul>\n<p>The <code>VELITE_STARTED</code> environment variable prevents Velite from starting multiple times during Next.js hot reloads. Without it, every time Next.js reloaded the config (when files change), Velite would start again, which causes issues.</p>\n<p>To verify this works, I changed the content in one of my blog posts without restarting the dev server. Within a second, the change appeared in the browser. I also checked my Vercel deploy logs and only saw one Velite build.</p>\n<p>Happy days. Back in business.</p>",
            "url": "https://lukemanning.ie/blog/fixing-velite-watch-mode-irony",
            "title": "I Fixed Velite Watch Mode Problem (And Immediately Broke It Again)",
            "summary": "<p>I just ran blog review agent on my <a href=\"/blog/adding-syntax-highlighting-shiki\">Adding Syntax Highlighting to Velite with Shiki</a> post. Made a few revisions to content including fixing tutorial framing, added version numbers, corrected CSS classes. Committed everything to git.</p>\n<p>Then I refreshed <a href=\"http://localhost:3000\">http://localhost:3000</a>.</p>\n<p>Still seeing old content. Weird. This never happens.</p>\n<h2>The Confusion</h2>\n<p>Honestly, this annoyed me more than it should have. I had just spent all this time fixing the post, and now I can't even see the changes?</p>\n<p>Velite was supposed to watch files automatically, like some content management systems do. Velite doesn't watch by default - you have to configure it. I'd written about this exact issue in a previous post. The irony was not lost on me.</p>\n<p>So I restarted the dev server, cleared cache, expecting everything to work. The new post showed up, but Velite still wasn't watching files. Changes only appeared when I restarted the server. This definitely is a regression on what I had previously configured.</p>\n<h2>What's Actually Going On?</h2>\n<p>Velite's docs say it supports watch mode, but it needs to be configured properly. The integration with Next.js happens through next.config.ts - that's where Velite gets initialized. If watch mode isn't set up there, Velite just runs once when the server starts and never again.</p>\n<p>I opened <code>next.config.ts</code> at the root of my project. Here's what the file looked like:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#F97583\"> type</span><span style=\"color:#E1E4E8\"> { NextConfig } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'next/config'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> nextConfig</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> NextConfig</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // some other config...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#E1E4E8\"> nextConfig</span></span></code></pre>\n<p>Oh. The Velite integration code was completely missing.</p>\n<p>I found the Velite integration code in their Next.js docs. Here's what should have been in next.config.ts:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#F97583\"> type</span><span style=\"color:#E1E4E8\"> { NextConfig } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'next/config'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> nextConfig</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> NextConfig</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // some other config...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// Velite integration</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isDev</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'development'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isBuild</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> (isDev </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> isBuild)) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> '1'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'velite'</span><span style=\"color:#E1E4E8\">).</span><span style=\"color:#B392F0\">then</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">m</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> m.</span><span style=\"color:#B392F0\">build</span><span style=\"color:#E1E4E8\">({ watch: isDev, clean: </span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isDev }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#E1E4E8\"> nextConfig</span></span></code></pre>\n<p>But there was nothing there. Just Next.js config.</p>\n<p>So that's why watch mode wasn't working. The code to start Velite at all was gone.</p>\n<h2>Why Was It Removed?</h2>\n<p>I checked git history to see when it disappeared. Found commit <code>1afe3e2</code> with message \"Duplicate Velite build issue\" that had removed all the Velite integration code. I figured I must have made a mistake.</p>\n<p>So I restored the code from git. Velite was running again. Watch mode was working. Changes appeared instantly.</p>\n<p>Then I remembered what the duplicate build issue actually was.</p>\n<h2>The Double Build Problem</h2>\n<p>When I deployed to Vercel, I was seeing Velite build twice in the logs:</p>\n<pre><code>[VELITE] building...\n[VELITE] building... again\n</code></pre>\n<p>The logs showed Velite building twice. I still don't know the exact root cause, but what I observed was: Next.js was triggering Velite during its build process, and something else (Vercel's Velite integration?) was also triggering a build. Two builds were happening.</p>\n<p>The original code was running Velite in both development (<code>isDev</code>) and production (<code>isBuild</code>). In production, when <code>isBuild</code> is true, Next.js runs during the build process. If Vercel also has its own Velite integration, both would be triggering Velite builds during deployment. That's inefficient. So commit <code>1afe3e2</code> removed the Velite integration entirely to stop the double builds.</p>\n<p>The problem with that approach? It also broke watch mode in development because the commit removed ALL the integration code.</p>\n<h2>The Actual Fix</h2>\n<p>The real solution is to only run Velite watch mode in development. Vercel handles Velite builds separately in production (I verified this by checking my Vercel project settings - there's a \"Velite\" section that shows it runs builds during deployment), so Next.js shouldn't trigger them.</p>\n<p>Here's what I ended up with:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isDev</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'development'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> isDev) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> '1'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'velite'</span><span style=\"color:#E1E4E8\">).</span><span style=\"color:#B392F0\">then</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">m</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> m.</span><span style=\"color:#B392F0\">build</span><span style=\"color:#E1E4E8\">({ watch: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">, clean: </span><span style=\"color:#79B8FF\">false</span><span style=\"color:#E1E4E8\"> }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>This is different from the original code:</p>\n<ul>\n<li>Removed <code>isBuild</code> - only run in development</li>\n<li>I ended up using <code>watch: true</code> in dev mode</li>\n<li>I ended up using <code>clean: false</code> in dev mode (I tried both settings. With <code>clean: true</code>, Velite would rebuild everything from scratch each time a file changed. With <code>clean: false</code>, it only rebuilt changed files, which was much faster)</li>\n<li>Vercel handles Velite builds separately in production (no watch mode needed)</li>\n</ul>\n<p>The <code>VELITE_STARTED</code> environment variable prevents Velite from starting multiple times during Next.js hot reloads. Without it, every time Next.js reloaded the config (when files change), Velite would start again, which causes issues.</p>\n<p>To verify this works, I changed the content in one of my blog posts without restarting the dev server. Within a second, the change appeared in the browser. I also checked my Vercel deploy logs and only saw one Velite build.</p>\n<p>Happy days. Back in business.</p>",
            "date_modified": "2026-03-23T00:00:00.000Z",
            "tags": [
                "velite",
                "nextjs"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/nuking-x-history-zerowork-taskbot",
            "content_html": "<p>I wanted to start using X more actively. Not for \"branding\" or \"growth hacking\" - just personal engagement, building relationships, connecting with likeminded people, sharing what I'm building. You know, building in public.</p>\n<h2>I Just Wanted to Delete Old Tweets</h2>\n<p>My X history was flooded with hundreds of tweets and retweets after years of neglect. There were so many corporate social media sharing campaign posts, random competition retweets, and so on. None of that history represented the new image I wanted to portray on X.</p>\n<p>But I've had my X profile for years, and I didn't want to delete it entirely.</p>\n<p>So I needed to nuke my history.</p>\n<p>This is where it got annoying. I looked around for tools to do this and found... third-party options. Sketchy-looking websites asking for my credentials. Paid subscriptions just to delete tweets. Nothing that felt safe or worth using.</p>\n<p>(I don't know if I'm overly paranoid, but giving my X login to a random website doesn't sit right with me.)</p>\n<p>I'd used ZeroWork before. I was pretty active in their community for a few months. Hadn't touched it in a while, but this problem sparked my interest in using it again.</p>\n<p>So I built my own solution.</p>\n<hr>\n<h2>Automating with ZeroWork</h2>\n<p>This automation completely cleans up your Twitter/X profile by automatically deleting all your original tweets and undoing all your retweets. Instead of manually clicking delete on hundreds of posts, this tool does the entire job for you in one go.</p>\n<p>The process works like this:</p>\n<ol>\n<li>Opens your profile - It starts by visiting your Twitter profile page</li>\n<li>Checks your Posts tab - It looks at your main Posts feed first to see if there are any tweets to clean up</li>\n<li>Deletes/undoes every tweet:\n<ul>\n<li>For retweets: It finds the \"Undo Retweet\" button and clicks it, removing your retweet</li>\n<li>For your own tweets: It clicks the more menu (⋮), selects \"Delete,\" and confirms the deletion</li>\n<li>It tracks how many tweets it deleted and how many retweets it undid</li>\n</ul>\n</li>\n<li>Automatically switches to Replies - If you don't have posts, it seamlessly moves to your Replies tab and cleans those up too</li>\n<li>Reports results - When it finishes, it tells you exactly what happened: \"Posts Feed Done | X Tweets Deleted | Y Retweets Undone\"</li>\n</ol>\n<p>Key features:</p>\n<ul>\n<li>Handles both posts and replies automatically</li>\n<li>Distinguishes between your tweets and retweets</li>\n<li>Safe, deliberate process with confirmation steps</li>\n<li>Auto-scrolls to find more tweets as it goes</li>\n</ul>\n<div class=\"border border-terminal-dim/15 p-4 my-6\">\n  <div class=\"text-xs text-terminal-dim mb-2\">\n    screenshot: <span class=\"text-terminal-accent\">twitter_taskbot.png</span>\n  </div>\n  <img src=\"/images/twitter_taskbot.png\" alt=\"ZeroWork TaskBot showing the delete tweets automation workflow\" class=\"rounded-sm w-full border border-terminal-dim/10\">\n</div>\n<p>Getting the TaskBot to work reliably took way more trial and error than I expected.</p>\n<p>The main thing that tripped me up was loop logic. If I set the loop iterations too high, it would randomly fail.</p>\n<p>So I realized the best thing to do was limit the loop to a single tweet, then add an \"After Repeat\" block to check if there were still more tweets. A single loop per single tweet was way more reliable than trying to process multiple tweets per loop.</p>\n<p>I also had to identify whether each object was a post or a retweet - the \"delete\" and \"un-repost\" actions are different, so I needed separate branches.</p>\n<p>But that wasn't the weird part.</p>\n<h2>The Weird Stuff</h2>\n<p>Older reposts/retweets were confusing. For some reason, they showed up in my Replies feed as me having retweeted them, but there was no \"Undo Retweet\" button. This broke my workflow since I couldn't just undo them directly.</p>\n<p>I figured it out relatively quickly: I had to repost the item again, then undo <em>that</em> repost. Only then would it finally get removed from the Replies page. This makes zero sense to me, but it worked.</p>\n<p>Then there were replies. Handling tweets where either I was replying to someone, or someone was replying to me - these got mixed together in the feed. The automation would try to delete tweets that weren't mine, which obviously failed.</p>\n<p>I solved this with a counter. My loop would always review the tweet at position zero. If that tweet belonged to someone else, I'd increment the counter, so it would review the tweet at position one instead. This way I could skip over other people's replies and only target mine.</p>\n<p>It wasn't plug-and-play. I was pretty rusty on ZeroWork, and I spent many hours tweaking and re-running the TaskBot before it finally worked smoothly.</p>\n<p>I let it run while I went to do other things. When I came back about 3 hours later, it had finished.</p>\n<p>It had removed about 400 reposts and a few tweets.</p>\n<p>Now I have a fresh X profile with 0 posts and 0 retweets.</p>\n<p>That's it. Clean slate. Ready to start posting for real this time.</p>",
            "url": "https://lukemanning.ie/blog/nuking-x-history-zerowork-taskbot",
            "title": "I Nuked My X History with a ZeroWork TaskBot",
            "summary": "<p>I wanted to start using X more actively. Not for \"branding\" or \"growth hacking\" - just personal engagement, building relationships, connecting with likeminded people, sharing what I'm building. You know, building in public.</p>\n<h2>I Just Wanted to Delete Old Tweets</h2>\n<p>My X history was flooded with hundreds of tweets and retweets after years of neglect. There were so many corporate social media sharing campaign posts, random competition retweets, and so on. None of that history represented the new image I wanted to portray on X.</p>\n<p>But I've had my X profile for years, and I didn't want to delete it entirely.</p>\n<p>So I needed to nuke my history.</p>\n<p>This is where it got annoying. I looked around for tools to do this and found... third-party options. Sketchy-looking websites asking for my credentials. Paid subscriptions just to delete tweets. Nothing that felt safe or worth using.</p>\n<p>(I don't know if I'm overly paranoid, but giving my X login to a random website doesn't sit right with me.)</p>\n<p>I'd used ZeroWork before. I was pretty active in their community for a few months. Hadn't touched it in a while, but this problem sparked my interest in using it again.</p>\n<p>So I built my own solution.</p>\n<hr>\n<h2>Automating with ZeroWork</h2>\n<p>This automation completely cleans up your Twitter/X profile by automatically deleting all your original tweets and undoing all your retweets. Instead of manually clicking delete on hundreds of posts, this tool does the entire job for you in one go.</p>\n<p>The process works like this:</p>\n<ol>\n<li>Opens your profile - It starts by visiting your Twitter profile page</li>\n<li>Checks your Posts tab - It looks at your main Posts feed first to see if there are any tweets to clean up</li>\n<li>Deletes/undoes every tweet:\n<ul>\n<li>For retweets: It finds the \"Undo Retweet\" button and clicks it, removing your retweet</li>\n<li>For your own tweets: It clicks the more menu (⋮), selects \"Delete,\" and confirms the deletion</li>\n<li>It tracks how many tweets it deleted and how many retweets it undid</li>\n</ul>\n</li>\n<li>Automatically switches to Replies - If you don't have posts, it seamlessly moves to your Replies tab and cleans those up too</li>\n<li>Reports results - When it finishes, it tells you exactly what happened: \"Posts Feed Done | X Tweets Deleted | Y Retweets Undone\"</li>\n</ol>\n<p>Key features:</p>\n<ul>\n<li>Handles both posts and replies automatically</li>\n<li>Distinguishes between your tweets and retweets</li>\n<li>Safe, deliberate process with confirmation steps</li>\n<li>Auto-scrolls to find more tweets as it goes</li>\n</ul>\n<div class=\"border border-terminal-dim/15 p-4 my-6\">\n  <div class=\"text-xs text-terminal-dim mb-2\">\n    screenshot: <span class=\"text-terminal-accent\">twitter_taskbot.png</span>\n  </div>\n  <img src=\"/images/twitter_taskbot.png\" alt=\"ZeroWork TaskBot showing the delete tweets automation workflow\" class=\"rounded-sm w-full border border-terminal-dim/10\">\n</div>\n<p>Getting the TaskBot to work reliably took way more trial and error than I expected.</p>\n<p>The main thing that tripped me up was loop logic. If I set the loop iterations too high, it would randomly fail.</p>\n<p>So I realized the best thing to do was limit the loop to a single tweet, then add an \"After Repeat\" block to check if there were still more tweets. A single loop per single tweet was way more reliable than trying to process multiple tweets per loop.</p>\n<p>I also had to identify whether each object was a post or a retweet - the \"delete\" and \"un-repost\" actions are different, so I needed separate branches.</p>\n<p>But that wasn't the weird part.</p>\n<h2>The Weird Stuff</h2>\n<p>Older reposts/retweets were confusing. For some reason, they showed up in my Replies feed as me having retweeted them, but there was no \"Undo Retweet\" button. This broke my workflow since I couldn't just undo them directly.</p>\n<p>I figured it out relatively quickly: I had to repost the item again, then undo <em>that</em> repost. Only then would it finally get removed from the Replies page. This makes zero sense to me, but it worked.</p>\n<p>Then there were replies. Handling tweets where either I was replying to someone, or someone was replying to me - these got mixed together in the feed. The automation would try to delete tweets that weren't mine, which obviously failed.</p>\n<p>I solved this with a counter. My loop would always review the tweet at position zero. If that tweet belonged to someone else, I'd increment the counter, so it would review the tweet at position one instead. This way I could skip over other people's replies and only target mine.</p>\n<p>It wasn't plug-and-play. I was pretty rusty on ZeroWork, and I spent many hours tweaking and re-running the TaskBot before it finally worked smoothly.</p>\n<p>I let it run while I went to do other things. When I came back about 3 hours later, it had finished.</p>\n<p>It had removed about 400 reposts and a few tweets.</p>\n<p>Now I have a fresh X profile with 0 posts and 0 retweets.</p>\n<p>That's it. Clean slate. Ready to start posting for real this time.</p>",
            "date_modified": "2026-03-22T00:00:00.000Z",
            "tags": [
                "homelab",
                "zerowork"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/velite-draft-filtering-not-working",
            "content_html": "<p>I deployed my blog to Vercel, and there they were. All 17 draft posts, live on the internet. Great.</p>\n<p>If you're also setting up a blog with Velite, I wrote about <a href=\"/blog/setting-up-velite-nextjs-revised\">the full Velite/Next.js setup process</a> as well — this was part of that same initial build.</p>\n<p>I had previously set up draft posts following <a href=\"/blog/adding-draft-posts-to-velite\">my guide on adding draft posts to Velite</a>. The frontmatter clearly said <code>draft: true</code>, but Velite didn't seem to care. My blog was showing posts that shouldn't exist yet - they appeared in the post list with full content visible, no indication they were drafts at all.</p>\n<h2>My Initial Setup</h2>\n<p>I'm using Velite 0.3.0 with Next.js 16.1.1. Here's what my <code>velite.config.js</code> looked like initially:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// velite.config.js (original)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      schema: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        // ... schema definition</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      },</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      filter</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">VERCEL_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft </span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  ],</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ... rest of config</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p>The logic seemed sound: check if we're on Vercel production, and if so, filter out draft posts. In development, show everything including drafts.</p>\n<p>This worked fine locally. I could see my drafts when writing, and published posts appeared on the blog. But when I deployed to Vercel, all my drafts went live.</p>\n<h2>First Suspicion: Maybe VERCEL_ENV Isn't Set at Build Time?</h2>\n<p>I had a suspicion that <code>VERCEL_ENV</code> wasn't being set correctly when Velite runs its build process. Vercel sets this environment variable, but maybe it's not available during the static site generation phase when Velite is processing posts.</p>\n<p>I changed the config to use <code>NODE_ENV</code> instead, since that's definitely set during the build:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// velite.config.js (attempt 1)</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft </span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>I was hoping this would work because <code>NODE_ENV</code> is set by Next.js when running <code>npm run build</code>, so it should be available when Velite processes the posts.</p>\n<h2>First Test: Still There</h2>\n<p>I ran the build:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">NODE_ENV</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">production</span><span style=\"color:#B392F0\"> npm</span><span style=\"color:#9ECBFF\"> run</span><span style=\"color:#9ECBFF\"> build</span></span></code></pre>\n<p>Then checked <code>.velite/posts.json</code> to see what was generated. All 18 posts. Including 17 drafts.</p>\n<p>I also checked what <code>VERCEL_ENV</code> was actually set to during the build:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// Quick debug check</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'VERCEL_ENV:'</span><span style=\"color:#E1E4E8\">, process.env.</span><span style=\"color:#79B8FF\">VERCEL_ENV</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft </span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Output: <code>VERCEL_ENV: undefined</code> - exactly what I suspected. The environment variable wasn't being set during Velite's build process.</p>\n<p>Okay, so the filter function wasn't working as I expected.</p>\n<h2>Next I Tried: Check Timing of Environment Evaluation</h2>\n<p>I wondered if <code>NODE_ENV</code> was being evaluated when the module loads instead of when Velite actually processes each post. If the module loads before <code>NODE_ENV</code> is set to 'production', the filter would always see development mode.</p>\n<p>But wait - that didn't make sense. I was explicitly running <code>NODE_ENV=production npm run build</code>, so the environment variable should be set before any Node modules load. The timing wasn't the issue.</p>\n<p>Something else was going on.</p>\n<h2>Does the Filter Function Even Run?</h2>\n<p>Let me see if the filter function is even being called:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Filtering post:'</span><span style=\"color:#E1E4E8\">, post.title, </span><span style=\"color:#9ECBFF\">'draft:'</span><span style=\"color:#E1E4E8\">, post.draft)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft </span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Ran the build. I was watching the terminal where I ran <code>npm run build</code>, but no logs appeared there. The filter function wasn't being invoked at all, or at least not in a way that would show logs during the build process.</p>\n<h2>So I Tested the Filter Logic Itself</h2>\n<p>Maybe the filter function syntax was wrong? Let me test by making it always return false, which should exclude all posts:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Filtering post:'</span><span style=\"color:#E1E4E8\">, post.title)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#79B8FF\"> false</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Built again. Checked <code>.velite/posts.json</code>. All 18 posts still there.</p>\n<p>At this point I was genuinely confused. The filter function exists in the Velite API, but it doesn't seem to affect the generated output at all. Either I'm using it wrong, or it's not doing what I think it does.</p>\n<h2>Filter in the complete() Callback</h2>\n<p>Velite has a <code>complete()</code> callback that runs after it generates all the data. Maybe filtering needs to happen there, since it's called after processing is complete?</p>\n<p>I thought this might work because in my mental model, Velite processed the posts first, then wrote the JSON files, then called <code>complete()</code> to do final cleanup. If that was the case, modifying <code>data.posts</code> in <code>complete()</code> would affect what got written to the JSON file.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">complete</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">data</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'isProduction:'</span><span style=\"color:#E1E4E8\">, isProduction)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Posts before filter:'</span><span style=\"color:#E1E4E8\">, data.posts.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (isProduction) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#79B8FF\"> draftCount</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> data.posts.</span><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">p</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> p.draft).</span><span style=\"color:#79B8FF\">length</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Filtering out'</span><span style=\"color:#E1E4E8\">, draftCount, </span><span style=\"color:#9ECBFF\">'draft posts'</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    data.posts </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> data.posts.</span><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#F97583\"> =></span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Posts after filter:'</span><span style=\"color:#E1E4E8\">, data.posts.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ... rest of function</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Ran <code>NODE_ENV=production npm run build</code>.</p>\n<p>Output:</p>\n<pre><code>isProduction: true\nPosts before filter: 18\nFiltering out 17 draft posts\nPosts after filter: 1\n</code></pre>\n<p>Okay, so the filtering logic itself works! The logs show 1 post after filtering.</p>\n<p>But when I opened <code>.velite/posts.json</code> to verify, I was surprised - all 18 posts were there. My mental model was wrong. The <code>complete()</code> callback must run AFTER the JSON files are written, or it doesn't affect the generated output files at all.</p>\n<p>Either way, the filtering I was doing wasn't making it into the JSON file.</p>\n<h2>The Breakthrough</h2>\n<p>Then it clicked. Velite generates the JSON, but I don't have to use all of it. My app imports posts from <code>.velite/posts.json</code> in <code>src/lib/posts.ts</code>, where I sort them and prepare them for display. Why not filter there too?</p>\n<p>This is runtime filtering - it happens right when the posts are accessed by my app, not during Velite's build process. That means <code>NODE_ENV</code> would definitely be set correctly because it's happening in the Next.js runtime.</p>\n<p>I know build-time filtering is theoretically faster since filtering happens once during the build instead of on every request. But at this point, I was tired of debugging the build process and wanted something that actually worked. I can always optimize later if performance becomes an issue - right now, reliability matters more.</p>\n<p>Here's what <code>src/lib/posts.ts</code> looked like before:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// src/lib/posts.ts (before)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> posts </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '.velite/posts.json'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { cache } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'react'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> getPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> cache</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  posts.</span><span style=\"color:#B392F0\">sort</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">a</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">b</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(b.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">() </span><span style=\"color:#F97583\">-</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(a.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">())</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span></span></code></pre>\n<p>And here's what I changed it to:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// src/lib/posts.ts (after)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> posts </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '.velite/posts.json'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { cache } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'react'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> filteredPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> posts.</span><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#F97583\"> =></span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft) </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> posts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> getPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> cache</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  filteredPosts.</span><span style=\"color:#B392F0\">sort</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">a</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">b</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(b.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">() </span><span style=\"color:#F97583\">-</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(a.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">())</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span></span></code></pre>\n<p>Now when <code>getPosts()</code> is called, it checks <code>NODE_ENV</code> and returns filtered posts if we're in production.</p>\n<p>Before deploying, I wanted to verify locally:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">NODE_ENV</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">production</span><span style=\"color:#B392F0\"> npm</span><span style=\"color:#9ECBFF\"> run</span><span style=\"color:#9ECBFF\"> build</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">NODE_ENV</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">production</span><span style=\"color:#B392F0\"> npm</span><span style=\"color:#9ECBFF\"> start</span></span></code></pre>\n<p>Opened localhost:3000 in the browser. Only the published post showed up. The drafts were gone.</p>\n<p>Deployed to Vercel. Checked the production URL directly (not from cache - used an incognito window to be sure). Only the published post shows up. The drafts are gone.</p>\n<hr>\n<p><strong>Quick note about preview deployments</strong>: The <code>NODE_ENV</code> check I used means preview deployments on Vercel will also hide draft posts, since <code>NODE_ENV</code> is 'production' in preview builds. If you want drafts to show in preview deployments, you'd need to check <code>VERCEL_ENV === 'production'</code> instead. But for my use case, hiding drafts in all non-dev environments works fine.</p>\n<hr>\n<h2>Cleaning Up</h2>\n<p>I removed the filter logic from <code>velite.config.js</code> since it wasn't working anyway:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// velite.config.js (final)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      schema: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        // ... schema definition</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      }</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      // No filter function - filtering happens in src/lib/posts.ts</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  ],</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ... rest of config</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p>Committed with message: \"Fix draft post filtering to work in production\"</p>\n<p>Ran <code>npm run lint</code> to make sure everything was clean. Deployed. Verified.</p>\n<p>Now my drafts stay drafts, and published posts are the only ones visible in production.</p>\n<p>I spent quite a while trying to make build-time filtering work in Velite before realizing runtime filtering in my app was actually the right approach.</p>",
            "url": "https://lukemanning.ie/blog/velite-draft-filtering-not-working",
            "title": "Draft Posts Still Showing in Production: My Velite Filtering Journey",
            "summary": "<p>I deployed my blog to Vercel, and there they were. All 17 draft posts, live on the internet. Great.</p>\n<p>If you're also setting up a blog with Velite, I wrote about <a href=\"/blog/setting-up-velite-nextjs-revised\">the full Velite/Next.js setup process</a> as well — this was part of that same initial build.</p>\n<p>I had previously set up draft posts following <a href=\"/blog/adding-draft-posts-to-velite\">my guide on adding draft posts to Velite</a>. The frontmatter clearly said <code>draft: true</code>, but Velite didn't seem to care. My blog was showing posts that shouldn't exist yet - they appeared in the post list with full content visible, no indication they were drafts at all.</p>\n<h2>My Initial Setup</h2>\n<p>I'm using Velite 0.3.0 with Next.js 16.1.1. Here's what my <code>velite.config.js</code> looked like initially:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// velite.config.js (original)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      schema: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        // ... schema definition</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      },</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      filter</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">VERCEL_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft </span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  ],</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ... rest of config</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p>The logic seemed sound: check if we're on Vercel production, and if so, filter out draft posts. In development, show everything including drafts.</p>\n<p>This worked fine locally. I could see my drafts when writing, and published posts appeared on the blog. But when I deployed to Vercel, all my drafts went live.</p>\n<h2>First Suspicion: Maybe VERCEL_ENV Isn't Set at Build Time?</h2>\n<p>I had a suspicion that <code>VERCEL_ENV</code> wasn't being set correctly when Velite runs its build process. Vercel sets this environment variable, but maybe it's not available during the static site generation phase when Velite is processing posts.</p>\n<p>I changed the config to use <code>NODE_ENV</code> instead, since that's definitely set during the build:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// velite.config.js (attempt 1)</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft </span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>I was hoping this would work because <code>NODE_ENV</code> is set by Next.js when running <code>npm run build</code>, so it should be available when Velite processes the posts.</p>\n<h2>First Test: Still There</h2>\n<p>I ran the build:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">NODE_ENV</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">production</span><span style=\"color:#B392F0\"> npm</span><span style=\"color:#9ECBFF\"> run</span><span style=\"color:#9ECBFF\"> build</span></span></code></pre>\n<p>Then checked <code>.velite/posts.json</code> to see what was generated. All 18 posts. Including 17 drafts.</p>\n<p>I also checked what <code>VERCEL_ENV</code> was actually set to during the build:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// Quick debug check</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'VERCEL_ENV:'</span><span style=\"color:#E1E4E8\">, process.env.</span><span style=\"color:#79B8FF\">VERCEL_ENV</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft </span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Output: <code>VERCEL_ENV: undefined</code> - exactly what I suspected. The environment variable wasn't being set during Velite's build process.</p>\n<p>Okay, so the filter function wasn't working as I expected.</p>\n<h2>Next I Tried: Check Timing of Environment Evaluation</h2>\n<p>I wondered if <code>NODE_ENV</code> was being evaluated when the module loads instead of when Velite actually processes each post. If the module loads before <code>NODE_ENV</code> is set to 'production', the filter would always see development mode.</p>\n<p>But wait - that didn't make sense. I was explicitly running <code>NODE_ENV=production npm run build</code>, so the environment variable should be set before any Node modules load. The timing wasn't the issue.</p>\n<p>Something else was going on.</p>\n<h2>Does the Filter Function Even Run?</h2>\n<p>Let me see if the filter function is even being called:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Filtering post:'</span><span style=\"color:#E1E4E8\">, post.title, </span><span style=\"color:#9ECBFF\">'draft:'</span><span style=\"color:#E1E4E8\">, post.draft)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft </span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Ran the build. I was watching the terminal where I ran <code>npm run build</code>, but no logs appeared there. The filter function wasn't being invoked at all, or at least not in a way that would show logs during the build process.</p>\n<h2>So I Tested the Filter Logic Itself</h2>\n<p>Maybe the filter function syntax was wrong? Let me test by making it always return false, which should exclude all posts:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Filtering post:'</span><span style=\"color:#E1E4E8\">, post.title)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#79B8FF\"> false</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Built again. Checked <code>.velite/posts.json</code>. All 18 posts still there.</p>\n<p>At this point I was genuinely confused. The filter function exists in the Velite API, but it doesn't seem to affect the generated output at all. Either I'm using it wrong, or it's not doing what I think it does.</p>\n<h2>Filter in the complete() Callback</h2>\n<p>Velite has a <code>complete()</code> callback that runs after it generates all the data. Maybe filtering needs to happen there, since it's called after processing is complete?</p>\n<p>I thought this might work because in my mental model, Velite processed the posts first, then wrote the JSON files, then called <code>complete()</code> to do final cleanup. If that was the case, modifying <code>data.posts</code> in <code>complete()</code> would affect what got written to the JSON file.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">complete</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">data</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'isProduction:'</span><span style=\"color:#E1E4E8\">, isProduction)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Posts before filter:'</span><span style=\"color:#E1E4E8\">, data.posts.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (isProduction) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#79B8FF\"> draftCount</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> data.posts.</span><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">p</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> p.draft).</span><span style=\"color:#79B8FF\">length</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Filtering out'</span><span style=\"color:#E1E4E8\">, draftCount, </span><span style=\"color:#9ECBFF\">'draft posts'</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    data.posts </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> data.posts.</span><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#F97583\"> =></span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Posts after filter:'</span><span style=\"color:#E1E4E8\">, data.posts.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ... rest of function</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Ran <code>NODE_ENV=production npm run build</code>.</p>\n<p>Output:</p>\n<pre><code>isProduction: true\nPosts before filter: 18\nFiltering out 17 draft posts\nPosts after filter: 1\n</code></pre>\n<p>Okay, so the filtering logic itself works! The logs show 1 post after filtering.</p>\n<p>But when I opened <code>.velite/posts.json</code> to verify, I was surprised - all 18 posts were there. My mental model was wrong. The <code>complete()</code> callback must run AFTER the JSON files are written, or it doesn't affect the generated output files at all.</p>\n<p>Either way, the filtering I was doing wasn't making it into the JSON file.</p>\n<h2>The Breakthrough</h2>\n<p>Then it clicked. Velite generates the JSON, but I don't have to use all of it. My app imports posts from <code>.velite/posts.json</code> in <code>src/lib/posts.ts</code>, where I sort them and prepare them for display. Why not filter there too?</p>\n<p>This is runtime filtering - it happens right when the posts are accessed by my app, not during Velite's build process. That means <code>NODE_ENV</code> would definitely be set correctly because it's happening in the Next.js runtime.</p>\n<p>I know build-time filtering is theoretically faster since filtering happens once during the build instead of on every request. But at this point, I was tired of debugging the build process and wanted something that actually worked. I can always optimize later if performance becomes an issue - right now, reliability matters more.</p>\n<p>Here's what <code>src/lib/posts.ts</code> looked like before:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// src/lib/posts.ts (before)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> posts </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '.velite/posts.json'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { cache } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'react'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> getPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> cache</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  posts.</span><span style=\"color:#B392F0\">sort</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">a</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">b</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(b.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">() </span><span style=\"color:#F97583\">-</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(a.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">())</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span></span></code></pre>\n<p>And here's what I changed it to:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// src/lib/posts.ts (after)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> posts </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '.velite/posts.json'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { cache } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'react'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> filteredPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> posts.</span><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#F97583\"> =></span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft) </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> posts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> getPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> cache</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  filteredPosts.</span><span style=\"color:#B392F0\">sort</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">a</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">b</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(b.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">() </span><span style=\"color:#F97583\">-</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(a.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">())</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span></span></code></pre>\n<p>Now when <code>getPosts()</code> is called, it checks <code>NODE_ENV</code> and returns filtered posts if we're in production.</p>\n<p>Before deploying, I wanted to verify locally:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">NODE_ENV</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">production</span><span style=\"color:#B392F0\"> npm</span><span style=\"color:#9ECBFF\"> run</span><span style=\"color:#9ECBFF\"> build</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">NODE_ENV</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">production</span><span style=\"color:#B392F0\"> npm</span><span style=\"color:#9ECBFF\"> start</span></span></code></pre>\n<p>Opened localhost:3000 in the browser. Only the published post showed up. The drafts were gone.</p>\n<p>Deployed to Vercel. Checked the production URL directly (not from cache - used an incognito window to be sure). Only the published post shows up. The drafts are gone.</p>\n<hr>\n<p><strong>Quick note about preview deployments</strong>: The <code>NODE_ENV</code> check I used means preview deployments on Vercel will also hide draft posts, since <code>NODE_ENV</code> is 'production' in preview builds. If you want drafts to show in preview deployments, you'd need to check <code>VERCEL_ENV === 'production'</code> instead. But for my use case, hiding drafts in all non-dev environments works fine.</p>\n<hr>\n<h2>Cleaning Up</h2>\n<p>I removed the filter logic from <code>velite.config.js</code> since it wasn't working anyway:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// velite.config.js (final)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      schema: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        // ... schema definition</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      }</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      // No filter function - filtering happens in src/lib/posts.ts</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  ],</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ... rest of config</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p>Committed with message: \"Fix draft post filtering to work in production\"</p>\n<p>Ran <code>npm run lint</code> to make sure everything was clean. Deployed. Verified.</p>\n<p>Now my drafts stay drafts, and published posts are the only ones visible in production.</p>\n<p>I spent quite a while trying to make build-time filtering work in Velite before realizing runtime filtering in my app was actually the right approach.</p>",
            "date_modified": "2026-03-22T00:00:00.000Z",
            "tags": [
                "velite",
                "nextjs"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/opencode-nested-slash-commands-architecture",
            "content_html": "<p>Here's what tripped me up: I tried to make a slash command call another slash command.</p>\n<p>I had two commands doing basically the same thing. One was my 4-agent blog review system—495 lines of voice validation, completeness checking, structure editing, and technical validation (I <a href=\"/blog/building-multi-agent-blog-review-system\">wrote about the full 4-agent system</a>). The other was a wrapper that auto-selects the next post needing review, then calls the first command. 239 lines of tracking and selection logic.</p>\n<h2>What I Tried (and Why It Failed)</h2>\n<p>The wrapper command was supposed to be simple: <code>/review-next-blog-post</code>. This wrapper then called the custom slash command <code>/review-blog-post-multi-agent @content/posts/specific-post</code> on the \"next\" post to be reviewed in my list of posts, based on criteria I defined.</p>\n<p>I tried different variations. Maybe the slash command syntax was wrong? Maybe I needed quotes or a different calling pattern? Still nothing. I spent way too long wondering why OpenCode was just... ignoring my nested command call.</p>\n<p>Turns out OpenCode slash commands can't call other slash commands. Each command is like a separate executable. You can't have <code>command-a</code> trigger <code>command-b</code> from within command-a's definition.</p>\n<h2>The Realization</h2>\n<p>This actually makes sense—predictable behavior, no infinite loops, no command calling itself. But I didn't know that going in, and the lack of error feedback made the confusion worse.</p>\n<p>I thought about a few options:</p>\n<ul>\n<li>Could I work around this somehow with some intermediate command?</li>\n<li>Maybe use the CLI tool to trigger commands?</li>\n<li>Wait—what if I just made one command do both?</li>\n</ul>\n<p>The last option felt right. Instead of trying to make commands nested, I needed a single command that could operate in two different modes.</p>\n<h2>The Solution: One Command, Two Modes OpenCode's <code>$1</code> argument system handles this perfectly:</h2>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Auto-Selection Mode (no arguments):**</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">`/review-blog-post-multi-agent`</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Direct Review Mode (specific post):**</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">`/review-blog-post-multi-agent @content/posts/blog-post-slug.md`</span><span style=\"color:#E1E4E8\"> or </span><span style=\"color:#79B8FF\">`path/to/blog-post.md`</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Mode Selection Logic:**</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> If </span><span style=\"color:#79B8FF\">`$1`</span><span style=\"color:#E1E4E8\"> is provided AND not empty → Direct Review Mode</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> If </span><span style=\"color:#79B8FF\">`$1`</span><span style=\"color:#E1E4E8\"> is empty or missing → Auto-Selection Mode</span></span></code></pre>\n<p>If <code>$1</code> is provided, use the file directly. If it's missing, run auto-selection. The command now does both modes in one place—finds the next post OR reviews the one you specify.</p>\n<h2>The Result</h2>\n<p>The consolidation was a net improvement: from 2 commands totaling 734 lines to 1 command with 632 lines. That's -102 lines while adding more functionality.</p>\n<p>Here's what changed:</p>\n<ul>\n<li><strong>Tracking logic</strong>: Moved from the wrapper command into <code>project_docs/blog-review-tracking.json</code> as reusable content</li>\n<li><strong>Auto-selection logic</strong>: Now part of the main command instead of a separate wrapper</li>\n<li><strong>Architecture</strong>: No nested dependencies—just one command that can operate in two modes</li>\n<li><strong>Usage</strong>: I can run <code>/review-blog-post-multi-agent</code> for auto-selection OR <code>/review-blog-post-multi-agent @content/posts/specific-post.md</code> for direct review</li>\n</ul>",
            "url": "https://lukemanning.ie/blog/opencode-nested-slash-commands-architecture",
            "title": "OpenCode Nested Commands: $1 Solution, -102 Lines",
            "summary": "<p>Here's what tripped me up: I tried to make a slash command call another slash command.</p>\n<p>I had two commands doing basically the same thing. One was my 4-agent blog review system—495 lines of voice validation, completeness checking, structure editing, and technical validation (I <a href=\"/blog/building-multi-agent-blog-review-system\">wrote about the full 4-agent system</a>). The other was a wrapper that auto-selects the next post needing review, then calls the first command. 239 lines of tracking and selection logic.</p>\n<h2>What I Tried (and Why It Failed)</h2>\n<p>The wrapper command was supposed to be simple: <code>/review-next-blog-post</code>. This wrapper then called the custom slash command <code>/review-blog-post-multi-agent @content/posts/specific-post</code> on the \"next\" post to be reviewed in my list of posts, based on criteria I defined.</p>\n<p>I tried different variations. Maybe the slash command syntax was wrong? Maybe I needed quotes or a different calling pattern? Still nothing. I spent way too long wondering why OpenCode was just... ignoring my nested command call.</p>\n<p>Turns out OpenCode slash commands can't call other slash commands. Each command is like a separate executable. You can't have <code>command-a</code> trigger <code>command-b</code> from within command-a's definition.</p>\n<h2>The Realization</h2>\n<p>This actually makes sense—predictable behavior, no infinite loops, no command calling itself. But I didn't know that going in, and the lack of error feedback made the confusion worse.</p>\n<p>I thought about a few options:</p>\n<ul>\n<li>Could I work around this somehow with some intermediate command?</li>\n<li>Maybe use the CLI tool to trigger commands?</li>\n<li>Wait—what if I just made one command do both?</li>\n</ul>\n<p>The last option felt right. Instead of trying to make commands nested, I needed a single command that could operate in two different modes.</p>\n<h2>The Solution: One Command, Two Modes OpenCode's <code>$1</code> argument system handles this perfectly:</h2>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Auto-Selection Mode (no arguments):**</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">`/review-blog-post-multi-agent`</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Direct Review Mode (specific post):**</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">`/review-blog-post-multi-agent @content/posts/blog-post-slug.md`</span><span style=\"color:#E1E4E8\"> or </span><span style=\"color:#79B8FF\">`path/to/blog-post.md`</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Mode Selection Logic:**</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> If </span><span style=\"color:#79B8FF\">`$1`</span><span style=\"color:#E1E4E8\"> is provided AND not empty → Direct Review Mode</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> If </span><span style=\"color:#79B8FF\">`$1`</span><span style=\"color:#E1E4E8\"> is empty or missing → Auto-Selection Mode</span></span></code></pre>\n<p>If <code>$1</code> is provided, use the file directly. If it's missing, run auto-selection. The command now does both modes in one place—finds the next post OR reviews the one you specify.</p>\n<h2>The Result</h2>\n<p>The consolidation was a net improvement: from 2 commands totaling 734 lines to 1 command with 632 lines. That's -102 lines while adding more functionality.</p>\n<p>Here's what changed:</p>\n<ul>\n<li><strong>Tracking logic</strong>: Moved from the wrapper command into <code>project_docs/blog-review-tracking.json</code> as reusable content</li>\n<li><strong>Auto-selection logic</strong>: Now part of the main command instead of a separate wrapper</li>\n<li><strong>Architecture</strong>: No nested dependencies—just one command that can operate in two modes</li>\n<li><strong>Usage</strong>: I can run <code>/review-blog-post-multi-agent</code> for auto-selection OR <code>/review-blog-post-multi-agent @content/posts/specific-post.md</code> for direct review</li>\n</ul>",
            "date_modified": "2026-01-13T00:00:00.000Z",
            "tags": [
                "opencode"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/git-corrupt-object-recovery",
            "content_html": "<p>Running <code>git log</code> broke my repository.</p>\n<p>I was on WSL (Windows Subsystem for Linux) working on my blog project like any other day. Ran <code>git log</code> to check my recent commits and got this:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">fatal:</span><span style=\"color:#9ECBFF\"> loose</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> b92de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#E1E4E8\"> (stored </span><span style=\"color:#9ECBFF\">in</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#E1E4E8\">) is corrupt</span></span></code></pre>\n<p>Three corrupt objects. Empty files. Admittedly I have somewhat limited Git experience, but I had never seen this before.</p>\n<p>Was I about to lose commits?</p>\n<hr>\n<h2>The Diagnosis</h2>\n<p>First, I needed to figure out how bad this was. I navigated to my repository root (where the <code>.git</code> folder is) and ran:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">$</span><span style=\"color:#9ECBFF\"> git</span><span style=\"color:#9ECBFF\"> fsck</span><span style=\"color:#79B8FF\"> --full</span></span></code></pre>\n<p>This command scans every object in your repository for corruption. The <code>--full</code> flag makes it check everything, not just what's reachable from current branches.</p>\n<p>The output was worse than I thought:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/28/7186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/28/7186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">fatal:</span><span style=\"color:#9ECBFF\"> loose</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> 287186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#E1E4E8\"> (stored </span><span style=\"color:#9ECBFF\">in</span><span style=\"color:#9ECBFF\"> .git/objects/28/7186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#E1E4E8\">) is corrupt</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">fatal:</span><span style=\"color:#9ECBFF\"> loose</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> b92de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#E1E4E8\"> (stored </span><span style=\"color:#9ECBFF\">in</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#E1E4E8\">) is corrupt</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/e2/ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/e2/ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">fatal:</span><span style=\"color:#9ECBFF\"> loose</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> e2ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span><span style=\"color:#E1E4E8\"> (stored </span><span style=\"color:#9ECBFF\">in</span><span style=\"color:#9ECBFF\"> .git/objects/e2/ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span><span style=\"color:#E1E4E8\">) is corrupt</span></span></code></pre>\n<p>Three corrupt object files. All empty (0 bytes).</p>\n<p>I didn't know what these objects were exactly - could have been commits, could have been file contents, could have been something else. That was the scary part. Were these unreferenced objects I wouldn't miss, or was I about to lose actual work?</p>\n<p>Git splits object hashes into two-character directories (<code>b9/</code>) to avoid having millions of files in a single folder. That's normal Git internals.</p>\n<p>If you're managing a home server and run into data recovery issues, I also wrote about <a href=\"/blog/unraid-docker-label-fix\">fixing Unraid Docker containers after an upgrade</a> — less Git-related, but same problem-solving mindset.</p>\n<h2>The Safety-First Question</h2>\n<p>The initial solution I got was:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">rm</span><span style=\"color:#9ECBFF\"> .git/objects/28/7186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">   .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">   .git/objects/e2/ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span></span></code></pre>\n<p>But I stopped. Because here's the thing: <strong>what if this doesn't work?</strong></p>\n<p>I asked: \"Do we have to <code>rm</code>? Can we <code>mv</code> in case this doesn't work? Or am I screwed either way?\"</p>\n<p>This turned out to be the right question. Even though these files were corrupt and empty, having a backup before doing anything destructive felt smarter. Even if it was arguably pointless. Sometimes you need a safety net, you know?</p>\n<h2>The Actual Fix</h2>\n<p>Here's what I did instead:</p>\n<p><strong>Step 1: Create a backup location</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">mkdir</span><span style=\"color:#79B8FF\"> -p</span><span style=\"color:#9ECBFF\"> .git/corrupt-backup</span></span></code></pre>\n<p>Will this work? I wasn't sure, but at least I'd have a backup if things went sideways.</p>\n<p><strong>Step 2: Move the corrupt files (don't delete)</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">mv</span><span style=\"color:#9ECBFF\"> .git/objects/28/7186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">   .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">   .git/objects/e2/ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">   .git/corrupt-backup/</span></span></code></pre>\n<p>Now the corrupt objects are gone from Git's perspective, but I still have them if I need to investigate later.</p>\n<p>I did try running <code>git fetch origin</code> before moving the files, but Git still threw the same corruption error. The empty files were blocking everything - they had to go.</p>\n<p><strong>Step 3: Recover from the remote</strong></p>\n<p>This was the scary part. From the same repository root, I ran:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> fetch</span><span style=\"color:#9ECBFF\"> origin</span></span></code></pre>\n<p>Git compared my local repo to GitHub, saw the missing objects, and re-downloaded them to <code>.git/objects/</code>. Because these commits existed on GitHub, I got back the exact object files I'd lost.</p>\n<p><code>origin</code> is the default name Git gives to your main remote. You can check your remotes with <code>git remote -v</code> if you're curious.</p>\n<p><strong>Step 4: Verify everything is fixed</strong></p>\n<p>That output looked good. But I needed to be sure. So I ran <code>fsck</code> again:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">$</span><span style=\"color:#9ECBFF\"> git</span><span style=\"color:#9ECBFF\"> fsck</span><span style=\"color:#79B8FF\"> --full</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">Checking</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> directories:</span><span style=\"color:#9ECBFF\"> 100%</span><span style=\"color:#E1E4E8\"> (256/256), done.</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">Checking</span><span style=\"color:#9ECBFF\"> objects:</span><span style=\"color:#9ECBFF\"> 100%</span><span style=\"color:#E1E4E8\"> (257/257), done.</span></span></code></pre>\n<p><strong>Step 5: Confirm Git commands work</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">$</span><span style=\"color:#9ECBFF\"> git</span><span style=\"color:#9ECBFF\"> log</span><span style=\"color:#79B8FF\"> --oneline</span><span style=\"color:#79B8FF\"> -10</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">b92de20</span><span style=\"color:#9ECBFF\"> Adding</span><span style=\"color:#9ECBFF\"> +2</span><span style=\"color:#9ECBFF\"> reviews</span><span style=\"color:#9ECBFF\"> for</span><span style=\"color:#9ECBFF\"> setup</span><span style=\"color:#9ECBFF\"> blog</span><span style=\"color:#9ECBFF\"> post</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">626e596</span><span style=\"color:#9ECBFF\"> refactor</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">content</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#9ECBFF\">:</span><span style=\"color:#9ECBFF\"> apply</span><span style=\"color:#9ECBFF\"> multi-agent</span><span style=\"color:#9ECBFF\"> review</span><span style=\"color:#9ECBFF\"> fixes</span><span style=\"color:#9ECBFF\"> to</span><span style=\"color:#9ECBFF\"> setting-up-velite-nextjs-revised</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">42f41d7</span><span style=\"color:#9ECBFF\"> Token</span><span style=\"color:#9ECBFF\"> optimization</span><span style=\"color:#9ECBFF\"> plan</span><span style=\"color:#9ECBFF\"> improvements</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">db5c595</span><span style=\"color:#9ECBFF\"> refactor</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">content</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#9ECBFF\">:</span><span style=\"color:#9ECBFF\"> apply</span><span style=\"color:#9ECBFF\"> multi-agent</span><span style=\"color:#9ECBFF\"> review</span><span style=\"color:#9ECBFF\"> fixes</span><span style=\"color:#9ECBFF\"> to</span><span style=\"color:#9ECBFF\"> setting-up-velite-nextjs-revised</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">4bf342a</span><span style=\"color:#9ECBFF\"> refactor</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">orchestrators</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#9ECBFF\">:</span><span style=\"color:#9ECBFF\"> use</span><span style=\"color:#9ECBFF\"> VOICE_GUIDE.md</span><span style=\"color:#9ECBFF\"> instead</span><span style=\"color:#9ECBFF\"> of</span><span style=\"color:#9ECBFF\"> reading</span><span style=\"color:#9ECBFF\"> recent</span><span style=\"color:#9ECBFF\"> posts</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">...</span></span></code></pre>\n<p>Everything works. Repository fully recovered.</p>\n<h2>What If You Don't Have a Remote?</h2>\n<p>Honestly, I got lucky. I had pushed everything to GitHub, so <code>git fetch origin</code> worked immediately.</p>\n<p>If I hadn't had a remote, I would have been in trouble. I could have tried <code>git reflog</code> to see if Git still had references locally, but if the commits weren't pushed and weren't in reflog, they'd be gone.</p>\n<p>This is why regular pushes matter - they're not just for sharing, they're for disaster recovery.</p>\n<hr>\n<h2>What I Learned</h2>\n<p>Object corruption happens. System crashes, disk issues, power loss, process killed at the wrong moment - any of these can leave you with empty 0-byte files in <code>.git/objects/</code>. I don't know which one hit me.</p>\n<p>Remote repositories are your safety net. Because I'd pushed these commits to GitHub, <code>git fetch</code> could restore them. The objects weren't actually lost, they just needed to be re-downloaded. If I hadn't pushed recently, this would've been scarier.</p>\n<p>Always have a rollback plan. Moving files instead of deleting them was the right call. Even though they were corrupt and useless, having them in <code>.git/corrupt-backup/</code> meant I could investigate or restore if something went wrong. I asked myself: \"before any destructive operation, do I have an escape hatch?\" That question saved me stress, even though I didn't end up needing the backup.</p>\n<p><code>git fsck</code> saved me time. When I first ran <code>git fsck --full</code>, I was overwhelmed by how verbose the output was. But once I realized it was showing me EXACTLY which three files were corrupt (with full paths and hashes), I knew what I needed to fix. Before this, I was guessing. After running fsck, I had precision.</p>\n<hr>\n<p>Git object corruption sounded terrifying when it first happened. But because I had GitHub and had pushed my commits, recovery was straightforward. The remote wasn't just for sharing code, it was my disaster recovery system.</p>\n<p>And that <code>mv</code> instead of <code>rm</code>? Saved me from unnecessary stress. Even though the files were genuinely corrupt and I never needed them again, having that backup gave me confidence to proceed.</p>",
            "url": "https://lukemanning.ie/blog/git-corrupt-object-recovery",
            "title": "Git Said My Objects Were Corrupt - How I Recovered Without Losing Anything",
            "summary": "<p>Running <code>git log</code> broke my repository.</p>\n<p>I was on WSL (Windows Subsystem for Linux) working on my blog project like any other day. Ran <code>git log</code> to check my recent commits and got this:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">fatal:</span><span style=\"color:#9ECBFF\"> loose</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> b92de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#E1E4E8\"> (stored </span><span style=\"color:#9ECBFF\">in</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#E1E4E8\">) is corrupt</span></span></code></pre>\n<p>Three corrupt objects. Empty files. Admittedly I have somewhat limited Git experience, but I had never seen this before.</p>\n<p>Was I about to lose commits?</p>\n<hr>\n<h2>The Diagnosis</h2>\n<p>First, I needed to figure out how bad this was. I navigated to my repository root (where the <code>.git</code> folder is) and ran:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">$</span><span style=\"color:#9ECBFF\"> git</span><span style=\"color:#9ECBFF\"> fsck</span><span style=\"color:#79B8FF\"> --full</span></span></code></pre>\n<p>This command scans every object in your repository for corruption. The <code>--full</code> flag makes it check everything, not just what's reachable from current branches.</p>\n<p>The output was worse than I thought:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/28/7186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/28/7186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">fatal:</span><span style=\"color:#9ECBFF\"> loose</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> 287186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#E1E4E8\"> (stored </span><span style=\"color:#9ECBFF\">in</span><span style=\"color:#9ECBFF\"> .git/objects/28/7186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#E1E4E8\">) is corrupt</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">fatal:</span><span style=\"color:#9ECBFF\"> loose</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> b92de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#E1E4E8\"> (stored </span><span style=\"color:#9ECBFF\">in</span><span style=\"color:#9ECBFF\"> .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#E1E4E8\">) is corrupt</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/e2/ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">error:</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#9ECBFF\"> .git/objects/e2/ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span><span style=\"color:#9ECBFF\"> is</span><span style=\"color:#9ECBFF\"> empty</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">fatal:</span><span style=\"color:#9ECBFF\"> loose</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> e2ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span><span style=\"color:#E1E4E8\"> (stored </span><span style=\"color:#9ECBFF\">in</span><span style=\"color:#9ECBFF\"> .git/objects/e2/ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span><span style=\"color:#E1E4E8\">) is corrupt</span></span></code></pre>\n<p>Three corrupt object files. All empty (0 bytes).</p>\n<p>I didn't know what these objects were exactly - could have been commits, could have been file contents, could have been something else. That was the scary part. Were these unreferenced objects I wouldn't miss, or was I about to lose actual work?</p>\n<p>Git splits object hashes into two-character directories (<code>b9/</code>) to avoid having millions of files in a single folder. That's normal Git internals.</p>\n<p>If you're managing a home server and run into data recovery issues, I also wrote about <a href=\"/blog/unraid-docker-label-fix\">fixing Unraid Docker containers after an upgrade</a> — less Git-related, but same problem-solving mindset.</p>\n<h2>The Safety-First Question</h2>\n<p>The initial solution I got was:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">rm</span><span style=\"color:#9ECBFF\"> .git/objects/28/7186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">   .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">   .git/objects/e2/ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span></span></code></pre>\n<p>But I stopped. Because here's the thing: <strong>what if this doesn't work?</strong></p>\n<p>I asked: \"Do we have to <code>rm</code>? Can we <code>mv</code> in case this doesn't work? Or am I screwed either way?\"</p>\n<p>This turned out to be the right question. Even though these files were corrupt and empty, having a backup before doing anything destructive felt smarter. Even if it was arguably pointless. Sometimes you need a safety net, you know?</p>\n<h2>The Actual Fix</h2>\n<p>Here's what I did instead:</p>\n<p><strong>Step 1: Create a backup location</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">mkdir</span><span style=\"color:#79B8FF\"> -p</span><span style=\"color:#9ECBFF\"> .git/corrupt-backup</span></span></code></pre>\n<p>Will this work? I wasn't sure, but at least I'd have a backup if things went sideways.</p>\n<p><strong>Step 2: Move the corrupt files (don't delete)</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">mv</span><span style=\"color:#9ECBFF\"> .git/objects/28/7186e66df4cf7057d2897bc1da0027affbfbe5</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">   .git/objects/b9/2de209f45b133daeeb97a46ea9ed354a2b9664</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">   .git/objects/e2/ec99bf1bb260a461c1b7670ad2e891ab0d7f88</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">   .git/corrupt-backup/</span></span></code></pre>\n<p>Now the corrupt objects are gone from Git's perspective, but I still have them if I need to investigate later.</p>\n<p>I did try running <code>git fetch origin</code> before moving the files, but Git still threw the same corruption error. The empty files were blocking everything - they had to go.</p>\n<p><strong>Step 3: Recover from the remote</strong></p>\n<p>This was the scary part. From the same repository root, I ran:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> fetch</span><span style=\"color:#9ECBFF\"> origin</span></span></code></pre>\n<p>Git compared my local repo to GitHub, saw the missing objects, and re-downloaded them to <code>.git/objects/</code>. Because these commits existed on GitHub, I got back the exact object files I'd lost.</p>\n<p><code>origin</code> is the default name Git gives to your main remote. You can check your remotes with <code>git remote -v</code> if you're curious.</p>\n<p><strong>Step 4: Verify everything is fixed</strong></p>\n<p>That output looked good. But I needed to be sure. So I ran <code>fsck</code> again:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">$</span><span style=\"color:#9ECBFF\"> git</span><span style=\"color:#9ECBFF\"> fsck</span><span style=\"color:#79B8FF\"> --full</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">Checking</span><span style=\"color:#9ECBFF\"> object</span><span style=\"color:#9ECBFF\"> directories:</span><span style=\"color:#9ECBFF\"> 100%</span><span style=\"color:#E1E4E8\"> (256/256), done.</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">Checking</span><span style=\"color:#9ECBFF\"> objects:</span><span style=\"color:#9ECBFF\"> 100%</span><span style=\"color:#E1E4E8\"> (257/257), done.</span></span></code></pre>\n<p><strong>Step 5: Confirm Git commands work</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">$</span><span style=\"color:#9ECBFF\"> git</span><span style=\"color:#9ECBFF\"> log</span><span style=\"color:#79B8FF\"> --oneline</span><span style=\"color:#79B8FF\"> -10</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">b92de20</span><span style=\"color:#9ECBFF\"> Adding</span><span style=\"color:#9ECBFF\"> +2</span><span style=\"color:#9ECBFF\"> reviews</span><span style=\"color:#9ECBFF\"> for</span><span style=\"color:#9ECBFF\"> setup</span><span style=\"color:#9ECBFF\"> blog</span><span style=\"color:#9ECBFF\"> post</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">626e596</span><span style=\"color:#9ECBFF\"> refactor</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">content</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#9ECBFF\">:</span><span style=\"color:#9ECBFF\"> apply</span><span style=\"color:#9ECBFF\"> multi-agent</span><span style=\"color:#9ECBFF\"> review</span><span style=\"color:#9ECBFF\"> fixes</span><span style=\"color:#9ECBFF\"> to</span><span style=\"color:#9ECBFF\"> setting-up-velite-nextjs-revised</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">42f41d7</span><span style=\"color:#9ECBFF\"> Token</span><span style=\"color:#9ECBFF\"> optimization</span><span style=\"color:#9ECBFF\"> plan</span><span style=\"color:#9ECBFF\"> improvements</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">db5c595</span><span style=\"color:#9ECBFF\"> refactor</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">content</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#9ECBFF\">:</span><span style=\"color:#9ECBFF\"> apply</span><span style=\"color:#9ECBFF\"> multi-agent</span><span style=\"color:#9ECBFF\"> review</span><span style=\"color:#9ECBFF\"> fixes</span><span style=\"color:#9ECBFF\"> to</span><span style=\"color:#9ECBFF\"> setting-up-velite-nextjs-revised</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">4bf342a</span><span style=\"color:#9ECBFF\"> refactor</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">orchestrators</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#9ECBFF\">:</span><span style=\"color:#9ECBFF\"> use</span><span style=\"color:#9ECBFF\"> VOICE_GUIDE.md</span><span style=\"color:#9ECBFF\"> instead</span><span style=\"color:#9ECBFF\"> of</span><span style=\"color:#9ECBFF\"> reading</span><span style=\"color:#9ECBFF\"> recent</span><span style=\"color:#9ECBFF\"> posts</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">...</span></span></code></pre>\n<p>Everything works. Repository fully recovered.</p>\n<h2>What If You Don't Have a Remote?</h2>\n<p>Honestly, I got lucky. I had pushed everything to GitHub, so <code>git fetch origin</code> worked immediately.</p>\n<p>If I hadn't had a remote, I would have been in trouble. I could have tried <code>git reflog</code> to see if Git still had references locally, but if the commits weren't pushed and weren't in reflog, they'd be gone.</p>\n<p>This is why regular pushes matter - they're not just for sharing, they're for disaster recovery.</p>\n<hr>\n<h2>What I Learned</h2>\n<p>Object corruption happens. System crashes, disk issues, power loss, process killed at the wrong moment - any of these can leave you with empty 0-byte files in <code>.git/objects/</code>. I don't know which one hit me.</p>\n<p>Remote repositories are your safety net. Because I'd pushed these commits to GitHub, <code>git fetch</code> could restore them. The objects weren't actually lost, they just needed to be re-downloaded. If I hadn't pushed recently, this would've been scarier.</p>\n<p>Always have a rollback plan. Moving files instead of deleting them was the right call. Even though they were corrupt and useless, having them in <code>.git/corrupt-backup/</code> meant I could investigate or restore if something went wrong. I asked myself: \"before any destructive operation, do I have an escape hatch?\" That question saved me stress, even though I didn't end up needing the backup.</p>\n<p><code>git fsck</code> saved me time. When I first ran <code>git fsck --full</code>, I was overwhelmed by how verbose the output was. But once I realized it was showing me EXACTLY which three files were corrupt (with full paths and hashes), I knew what I needed to fix. Before this, I was guessing. After running fsck, I had precision.</p>\n<hr>\n<p>Git object corruption sounded terrifying when it first happened. But because I had GitHub and had pushed my commits, recovery was straightforward. The remote wasn't just for sharing code, it was my disaster recovery system.</p>\n<p>And that <code>mv</code> instead of <code>rm</code>? Saved me from unnecessary stress. Even though the files were genuinely corrupt and I never needed them again, having that backup gave me confidence to proceed.</p>",
            "date_modified": "2026-01-08T00:00:00.000Z",
            "tags": [
                "github"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/adding-draft-posts-to-velite",
            "content_html": "<p>I previously wrote about my multi-agent blog post generation system that takes my raw conversations and notes and generates structured posts from them: <a href=\"/blog/building-multi-agent-blog-review-system\">I Built a Multi-Agent System to Review My Blog Posts</a>.</p>\n<p>These posts are often half-finished thoughts and content that I didn't want to be published yet. So to avoid this happening, but to still give me the ability to create a base post to work from, I thought I'd enable drafts that would be hidden until I was ready to publish.</p>\n<p>\"Just add draft: true to frontmatter,\" I thought. Every other static site generator does this, right?</p>\n<p>Spoiler: Velite doesn't work that way.</p>\n<p>Here's what I tried first:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">title</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"Work in Progress\"</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">date</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2025-01-03</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">draft</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Still writing this...</span></span></code></pre>\n<p>Except... it doesn't work that way.</p>\n<h2>The Question</h2>\n<p>\"Does Velite automatically consider all frontmatter (those YAML settings at the top of my markdown files between the <code>---</code> markers), or do we need to handle each value separately?\"</p>\n<p>I assumed Velite would auto-pick up any field I put in frontmatter. Add <code>draft: true</code>, filter posts by <code>post.draft</code>, done.</p>\n<p>That's not how Velite works.</p>\n<h2>What I'm Starting With</h2>\n<p>Before I get into the solution, here's what my setup looked like:</p>\n<p><strong>My existing Velite schema</strong> (<code>/velite.config.js</code>):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> rehypeShiki </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@shikijs/rehype'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { defineConfig, s } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    posts: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'Post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      schema: s</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          title: s.</span><span style=\"color:#B392F0\">string</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">max</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">99</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          slug: s.</span><span style=\"color:#B392F0\">slug</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'posts'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          date: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          lastModified: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          cover: s.</span><span style=\"color:#B392F0\">image</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          video: s.</span><span style=\"color:#B392F0\">file</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          metadata: s.</span><span style=\"color:#B392F0\">metadata</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          excerpt: s.</span><span style=\"color:#B392F0\">excerpt</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          content: s.</span><span style=\"color:#B392F0\">markdown</span><span style=\"color:#E1E4E8\">()</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        })</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">transform</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">data</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">data, permalink: </span><span style=\"color:#9ECBFF\">`/blog/${</span><span style=\"color:#E1E4E8\">data</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">slug</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\"> }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  markdown: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    rehypePlugins: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      [rehypeShiki, { theme: </span><span style=\"color:#9ECBFF\">'github-dark'</span><span style=\"color:#E1E4E8\"> }]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p><strong>Prerequisites</strong>: You should have:</p>\n<ul>\n<li>Velite 0.3.0+ installed</li>\n<li>A working posts collection</li>\n<li>Frontmatter with title, slug, date fields</li>\n</ul>\n<p>If your setup looks different, the draft field addition should still work. You'll just need to adapt the schema structure.</p>\n<h2>How Velite Actually Works</h2>\n<p>I'm using Velite 0.3.0 with Next.js 16.1.1, and this is where I learned something important.</p>\n<p>Velite is <strong>schema-first</strong>. Every field must be explicitly defined in your schema in <code>/velite.config.js</code>.</p>\n<p>From <a href=\"https://velite.js.org/guide/schema\">Velite's official docs</a>:</p>\n<blockquote>\n<p>The schema property defines the structure and types of the data within a collection. You use Velite's schema system to specify fields, their types, and whether they are optional.</p>\n</blockquote>\n<p>No schema field? No access to that data. Period.</p>\n<p>This means:</p>\n<ol>\n<li><strong>Schema defines fields</strong> - What fields exist on your posts</li>\n<li><strong>Frontmatter provides values</strong> - The actual data for those fields</li>\n<li><strong>Velite validates</strong> - Ensures frontmatter matches schema at build time</li>\n</ol>\n<p>If <code>draft</code> isn't in your schema, Velite ignores it in frontmatter.</p>\n<h2>What I Actually Did (And Got Wrong First)</h2>\n<p>Adding draft functionality requires <strong>two changes</strong>. I thought I could skip the first one. I was wrong.</p>\n<h3>1. Add the Field to Your Schema</h3>\n<p>I figured Velite would just... pick up the field. Add <code>draft: true</code> to frontmatter, access <code>post.draft</code> in my code, done.</p>\n<p>Nope.</p>\n<p>Velite ignored my <code>draft</code> field entirely. No error, no warning, nothing. The post compiled, the field was just... gone.</p>\n<p>Here's what I added to my config:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { defineCollection, defineConfig } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { MDXPlugin } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite-remark'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> rehypeSlug </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'rehype-slug'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> rehypeAutolinkHeadings </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'rehype-autolink-headings'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> posts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> defineCollection</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  name: </span><span style=\"color:#9ECBFF\">'Post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  schema: s.</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    title: s.</span><span style=\"color:#B392F0\">string</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">max</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">99</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    slug: s.</span><span style=\"color:#B392F0\">slug</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'posts'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    date: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    draft: s.</span><span style=\"color:#B392F0\">boolean</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">default</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">false</span><span style=\"color:#E1E4E8\">), </span><span style=\"color:#6A737D\">// ← Add this line</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // ... other existing fields like description, tags, etc.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  mdx: </span><span style=\"color:#B392F0\">MDXPlugin</span><span style=\"color:#E1E4E8\">({ rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings] })</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: { posts }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p>In my case, I had MDX plugins set up for syntax highlighting. If your setup is simpler, the schema addition looks like this:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> posts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> defineCollection</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  name: </span><span style=\"color:#9ECBFF\">'Post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  schema: s.</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    title: s.</span><span style=\"color:#B392F0\">string</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">max</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">99</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    slug: s.</span><span style=\"color:#B392F0\">slug</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'posts'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    date: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    draft: s.</span><span style=\"color:#B392F0\">boolean</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">default</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">false</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  })</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({ collections: { posts } })</span></span></code></pre>\n<h3>Understanding the Schema Builder</h3>\n<p>The <code>s</code> object you see in the code examples is the schema builder. It's provided by <code>defineCollection</code> and gives you type-safe field definitions:</p>\n<ul>\n<li><code>s.string()</code> - text fields</li>\n<li><code>s.boolean()</code> - true/false values</li>\n<li><code>s.isodate()</code> - date fields (validated as ISO format)</li>\n<li><code>s.slug()</code> - URL-friendly slugs</li>\n<li><code>.optional()</code> - field doesn't have to exist</li>\n<li><code>.default(false)</code> - if missing, use this value</li>\n<li><code>.max(99)</code> - string length limit</li>\n</ul>\n<h3>Why I Set It Up This Way</h3>\n<p>I chose <code>.optional().default(false)</code> and <code>.boolean()</code> for specific reasons:</p>\n<ul>\n<li><code>.optional()</code> — not every post needs a draft flag, so we don't require it</li>\n<li><code>.default(false)</code> — posts are published by default (safer than assuming draft)</li>\n<li><code>.boolean()</code> — catches typos like <code>draft: yes</code> instead of <code>true</code>, failing at build time</li>\n</ul>\n<h3>Don't Forget Cache Clearing</h3>\n<p>I saved the config and restarted the dev server.</p>\n<p>Still no <code>draft</code> field in my posts. I checked <code>.velite/posts.json</code>. Nothing.</p>\n<p>Hmm. Maybe I typoed something in the schema? No, looks fine. Maybe the MDX plugins are interfering? I tried simplifying the config to just the schema. Still nothing.</p>\n<p>At this point I remembered: Velite caches everything. Schema changes mean the cached data is invalid.</p>\n<p>I ran <code>npm run clean:dev</code> (which removes both <code>.next</code> and <code>.velite</code> folders) and restarted the dev server.</p>\n<p>This time, <code>.velite/posts.json</code> had the <code>draft</code> field for every post:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"title\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"Work in Progress\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"slug\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"work-in-progress\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"date\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"2025-01-03T00:00:00.000Z\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"draft\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ... other fields</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Posts without <code>draft: true</code> in frontmatter had <code>\"draft\": false</code> (from the <code>.default(false)</code>). I tested a typo (<code>darft: true</code>) which resulted in the field being ignored entirely, defaulting to <code>false</code>. Nice.</p>\n<p>Lesson learned: Schema changes in Velite require clearing the cache. It's annoying, but it prevents stale cached data from causing weird bugs.</p>\n<h3>2. Add Environment-Aware Filtering</h3>\n<p>But having the field isn't enough. You also need to <strong>filter out drafts</strong> based on environment.</p>\n<p>I needed:</p>\n<ul>\n<li><strong>Local dev</strong>: Show all posts (including drafts)</li>\n<li><strong>Vercel preview</strong>: Show all posts (for review)</li>\n<li><strong>Production</strong>: Hide drafts</li>\n</ul>\n<p>I considered using Vercel's <code>VERCEL_ENV</code> at first. That's the \"proper\" Vercel way. But I was already using <code>NODE_ENV</code> elsewhere in my config, so I stuck with that.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span></code></pre>\n<p>This works because:</p>\n<ul>\n<li>Local dev means <code>NODE_ENV=development</code>, so show drafts</li>\n<li>Production build means <code>NODE_ENV=production</code>, so hide drafts</li>\n</ul>\n<p>For the filtering, I added it in <code>/src/lib/posts.ts</code> rather than the Velite config. Here's what that looks like:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { cache } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'react'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { posts } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '#site/content'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// Filter posts at the application level</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> filteredPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> posts.</span><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#F97583\"> =></span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft) </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> posts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// All exported functions use filteredPosts instead of raw posts</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> getPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> cache</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  filteredPosts.</span><span style=\"color:#B392F0\">sort</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">a</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">b</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(b.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">() </span><span style=\"color:#F97583\">-</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(a.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">())</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> getPostBySlug</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> cache</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">slug</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  filteredPosts.</span><span style=\"color:#B392F0\">find</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">p</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> p.slug </span><span style=\"color:#F97583\">===</span><span style=\"color:#E1E4E8\"> slug)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span></span></code></pre>\n<p>I filtered at the application level because it keeps the Velite config simple. I just need the schema field. The filtering logic lives with the rest of the post retrieval code, which felt cleaner to me.</p>\n<h2>How Draft Filtering Works Across Environments</h2>\n<p>Here's how it behaves:</p>\n<ul>\n<li><strong>Local dev</strong> (<code>NODE_ENV=development</code>) means show drafts</li>\n<li><strong>Vercel preview</strong> (<code>NODE_ENV=production</code>) means hide drafts</li>\n<li><strong>Production</strong> (<code>NODE_ENV=production</code>) means hide drafts</li>\n</ul>\n<p>The simple check <code>process.env.NODE_ENV === 'production'</code> handles all cases:</p>\n<ul>\n<li>Local dev gives <code>isProduction = false</code>, so show drafts</li>\n<li>Production build gives <code>isProduction = true</code>, so hide drafts</li>\n</ul>\n<h2>Why I'm Okay With This Now</h2>\n<p>Honestly, defining every field in the schema felt annoying at first. More boilerplate, more config.</p>\n<p>But then I thought about what would happen without it:</p>\n<p><strong>Typo <code>draft: true</code> as <code>darft: true</code>?</strong>\nBuild succeeds. Draft goes live. You don't notice until someone emails you.</p>\n<p><strong>Inconsistent values</strong> like <code>draft: yes</code> or <code>draft: 1</code>?\nNo type checking. Your filter logic breaks silently.</p>\n<p><strong>Rename <code>draft</code> to <code>published</code> in frontmatter</strong> but forget to update code?\nNo build error. Filtering stops working.</p>\n<p>Schema-first means:</p>\n<ul>\n<li>Typos in field names → build fails immediately</li>\n<li>Wrong types → Velite catches it at build time</li>\n<li>Refactoring → TypeScript shows all usage</li>\n</ul>\n<p>The 30 seconds to add a schema field saves hours of \"why isn't this working?\" debugging.</p>\n<h2>What About Vercel Deployments?</h2>\n<p>I initially thought about using <code>VERCEL_ENV</code> since I'm deploying to Vercel. That's what their docs recommend for detecting preview vs production deployments. But <code>NODE_ENV</code> works fine for my use case:</p>\n<ul>\n<li>I don't need separate preview deployments with different draft visibility</li>\n<li><code>NODE_ENV</code> is already set by Vercel in production builds</li>\n<li>I was already using <code>NODE_ENV</code> elsewhere in my config</li>\n</ul>\n<p>If you need draft posts visible in preview deployments but hidden in production, <code>VERCEL_ENV</code> is the way to go:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">VERCEL_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// Then use the same filtering logic</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> filteredPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> posts.</span><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#F97583\"> =></span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft) </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> posts</span></span></code></pre>\n<p>But for me, <code>NODE_ENV</code> is simpler and works just as well.</p>\n<h2>My Actual Testing Experience</h2>\n<p>I created a test draft post to verify everything worked:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">title</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"Test Draft\"</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">slug</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">test-draft</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">date</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2025-01-03</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">draft</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">This should only appear in dev/preview.</span></span></code></pre>\n<p>First, I ran <code>npm run dev</code>. The draft showed up in my post list. I clicked through to <code>/blog/test-draft</code> and saw the content. Good.</p>\n<p>Then I ran <code>npm run build</code> followed by <code>npm run start</code> to test production behavior.</p>\n<p>Expected: Post appears in list, I can read it.\nActual: Post is gone from the list. <code>/blog/test-draft</code> returns 404.</p>\n<p>Exactly what I wanted. But I wanted to double-check the build output, so I opened <code>.velite/posts.json</code>. Wait. The <code>test-draft</code> entry was still there.</p>\n<p>That's weird. I thought it would be filtered out by Velite?</p>\n<p>Oh right. I'm filtering at the application level in <code>posts.ts</code>, not in the Velite config. Velite builds all posts, including drafts, into <code>.velite/posts.json</code>. Then my application code filters them out when retrieving posts.</p>\n<p>I verified this by checking the RSS feed. In the Velite config's <code>complete</code> callback, I filter out drafts before adding to the feed. So draft posts don't show up in the RSS. But <code>.velite/posts.json</code> still contains everything.</p>\n<p>This is actually fine. The filtering happens where I retrieve posts, so draft posts never reach my components. The build output includes everything, but my app only serves what's appropriate for the environment.</p>\n<h2>What I Learned</h2>\n<p><strong>Velite doesn't auto-pick up frontmatter fields.</strong> You must define them in your schema first.</p>\n<p>This felt like a constraint at first. Now I see it as a safety net:</p>\n<ul>\n<li>Build-time validation catches mistakes</li>\n<li>TypeScript knows what fields exist</li>\n<li>Impossible states become... impossible</li>\n</ul>\n<p>The schema isn't boilerplate. It's the contract between your content and your code.</p>\n<p>That <code>darft: true</code> typo? Can't happen. Velite would ignore the field entirely, and TypeScript would show an error when you try to access <code>post.darft</code>.</p>\n<p>Schema-first design saves future-you from current-you's mistakes.</p>",
            "url": "https://lukemanning.ie/blog/adding-draft-posts-to-velite",
            "title": "Adding Draft Posts to Velite: Why 'draft: true' Isn't Enough",
            "summary": "<p>I previously wrote about my multi-agent blog post generation system that takes my raw conversations and notes and generates structured posts from them: <a href=\"/blog/building-multi-agent-blog-review-system\">I Built a Multi-Agent System to Review My Blog Posts</a>.</p>\n<p>These posts are often half-finished thoughts and content that I didn't want to be published yet. So to avoid this happening, but to still give me the ability to create a base post to work from, I thought I'd enable drafts that would be hidden until I was ready to publish.</p>\n<p>\"Just add draft: true to frontmatter,\" I thought. Every other static site generator does this, right?</p>\n<p>Spoiler: Velite doesn't work that way.</p>\n<p>Here's what I tried first:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">title</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"Work in Progress\"</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">date</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2025-01-03</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">draft</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Still writing this...</span></span></code></pre>\n<p>Except... it doesn't work that way.</p>\n<h2>The Question</h2>\n<p>\"Does Velite automatically consider all frontmatter (those YAML settings at the top of my markdown files between the <code>---</code> markers), or do we need to handle each value separately?\"</p>\n<p>I assumed Velite would auto-pick up any field I put in frontmatter. Add <code>draft: true</code>, filter posts by <code>post.draft</code>, done.</p>\n<p>That's not how Velite works.</p>\n<h2>What I'm Starting With</h2>\n<p>Before I get into the solution, here's what my setup looked like:</p>\n<p><strong>My existing Velite schema</strong> (<code>/velite.config.js</code>):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> rehypeShiki </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@shikijs/rehype'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { defineConfig, s } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    posts: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'Post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      schema: s</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          title: s.</span><span style=\"color:#B392F0\">string</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">max</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">99</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          slug: s.</span><span style=\"color:#B392F0\">slug</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'posts'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          date: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          lastModified: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          cover: s.</span><span style=\"color:#B392F0\">image</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          video: s.</span><span style=\"color:#B392F0\">file</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          metadata: s.</span><span style=\"color:#B392F0\">metadata</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          excerpt: s.</span><span style=\"color:#B392F0\">excerpt</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          content: s.</span><span style=\"color:#B392F0\">markdown</span><span style=\"color:#E1E4E8\">()</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        })</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">transform</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">data</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">data, permalink: </span><span style=\"color:#9ECBFF\">`/blog/${</span><span style=\"color:#E1E4E8\">data</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">slug</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\"> }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  markdown: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    rehypePlugins: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      [rehypeShiki, { theme: </span><span style=\"color:#9ECBFF\">'github-dark'</span><span style=\"color:#E1E4E8\"> }]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p><strong>Prerequisites</strong>: You should have:</p>\n<ul>\n<li>Velite 0.3.0+ installed</li>\n<li>A working posts collection</li>\n<li>Frontmatter with title, slug, date fields</li>\n</ul>\n<p>If your setup looks different, the draft field addition should still work. You'll just need to adapt the schema structure.</p>\n<h2>How Velite Actually Works</h2>\n<p>I'm using Velite 0.3.0 with Next.js 16.1.1, and this is where I learned something important.</p>\n<p>Velite is <strong>schema-first</strong>. Every field must be explicitly defined in your schema in <code>/velite.config.js</code>.</p>\n<p>From <a href=\"https://velite.js.org/guide/schema\">Velite's official docs</a>:</p>\n<blockquote>\n<p>The schema property defines the structure and types of the data within a collection. You use Velite's schema system to specify fields, their types, and whether they are optional.</p>\n</blockquote>\n<p>No schema field? No access to that data. Period.</p>\n<p>This means:</p>\n<ol>\n<li><strong>Schema defines fields</strong> - What fields exist on your posts</li>\n<li><strong>Frontmatter provides values</strong> - The actual data for those fields</li>\n<li><strong>Velite validates</strong> - Ensures frontmatter matches schema at build time</li>\n</ol>\n<p>If <code>draft</code> isn't in your schema, Velite ignores it in frontmatter.</p>\n<h2>What I Actually Did (And Got Wrong First)</h2>\n<p>Adding draft functionality requires <strong>two changes</strong>. I thought I could skip the first one. I was wrong.</p>\n<h3>1. Add the Field to Your Schema</h3>\n<p>I figured Velite would just... pick up the field. Add <code>draft: true</code> to frontmatter, access <code>post.draft</code> in my code, done.</p>\n<p>Nope.</p>\n<p>Velite ignored my <code>draft</code> field entirely. No error, no warning, nothing. The post compiled, the field was just... gone.</p>\n<p>Here's what I added to my config:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { defineCollection, defineConfig } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { MDXPlugin } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite-remark'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> rehypeSlug </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'rehype-slug'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> rehypeAutolinkHeadings </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'rehype-autolink-headings'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> posts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> defineCollection</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  name: </span><span style=\"color:#9ECBFF\">'Post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  schema: s.</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    title: s.</span><span style=\"color:#B392F0\">string</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">max</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">99</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    slug: s.</span><span style=\"color:#B392F0\">slug</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'posts'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    date: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    draft: s.</span><span style=\"color:#B392F0\">boolean</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">default</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">false</span><span style=\"color:#E1E4E8\">), </span><span style=\"color:#6A737D\">// ← Add this line</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // ... other existing fields like description, tags, etc.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  mdx: </span><span style=\"color:#B392F0\">MDXPlugin</span><span style=\"color:#E1E4E8\">({ rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings] })</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: { posts }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p>In my case, I had MDX plugins set up for syntax highlighting. If your setup is simpler, the schema addition looks like this:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> posts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> defineCollection</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  name: </span><span style=\"color:#9ECBFF\">'Post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  schema: s.</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    title: s.</span><span style=\"color:#B392F0\">string</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">max</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">99</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    slug: s.</span><span style=\"color:#B392F0\">slug</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'posts'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    date: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    draft: s.</span><span style=\"color:#B392F0\">boolean</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">default</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">false</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  })</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({ collections: { posts } })</span></span></code></pre>\n<h3>Understanding the Schema Builder</h3>\n<p>The <code>s</code> object you see in the code examples is the schema builder. It's provided by <code>defineCollection</code> and gives you type-safe field definitions:</p>\n<ul>\n<li><code>s.string()</code> - text fields</li>\n<li><code>s.boolean()</code> - true/false values</li>\n<li><code>s.isodate()</code> - date fields (validated as ISO format)</li>\n<li><code>s.slug()</code> - URL-friendly slugs</li>\n<li><code>.optional()</code> - field doesn't have to exist</li>\n<li><code>.default(false)</code> - if missing, use this value</li>\n<li><code>.max(99)</code> - string length limit</li>\n</ul>\n<h3>Why I Set It Up This Way</h3>\n<p>I chose <code>.optional().default(false)</code> and <code>.boolean()</code> for specific reasons:</p>\n<ul>\n<li><code>.optional()</code> — not every post needs a draft flag, so we don't require it</li>\n<li><code>.default(false)</code> — posts are published by default (safer than assuming draft)</li>\n<li><code>.boolean()</code> — catches typos like <code>draft: yes</code> instead of <code>true</code>, failing at build time</li>\n</ul>\n<h3>Don't Forget Cache Clearing</h3>\n<p>I saved the config and restarted the dev server.</p>\n<p>Still no <code>draft</code> field in my posts. I checked <code>.velite/posts.json</code>. Nothing.</p>\n<p>Hmm. Maybe I typoed something in the schema? No, looks fine. Maybe the MDX plugins are interfering? I tried simplifying the config to just the schema. Still nothing.</p>\n<p>At this point I remembered: Velite caches everything. Schema changes mean the cached data is invalid.</p>\n<p>I ran <code>npm run clean:dev</code> (which removes both <code>.next</code> and <code>.velite</code> folders) and restarted the dev server.</p>\n<p>This time, <code>.velite/posts.json</code> had the <code>draft</code> field for every post:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"title\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"Work in Progress\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"slug\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"work-in-progress\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"date\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"2025-01-03T00:00:00.000Z\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"draft\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ... other fields</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Posts without <code>draft: true</code> in frontmatter had <code>\"draft\": false</code> (from the <code>.default(false)</code>). I tested a typo (<code>darft: true</code>) which resulted in the field being ignored entirely, defaulting to <code>false</code>. Nice.</p>\n<p>Lesson learned: Schema changes in Velite require clearing the cache. It's annoying, but it prevents stale cached data from causing weird bugs.</p>\n<h3>2. Add Environment-Aware Filtering</h3>\n<p>But having the field isn't enough. You also need to <strong>filter out drafts</strong> based on environment.</p>\n<p>I needed:</p>\n<ul>\n<li><strong>Local dev</strong>: Show all posts (including drafts)</li>\n<li><strong>Vercel preview</strong>: Show all posts (for review)</li>\n<li><strong>Production</strong>: Hide drafts</li>\n</ul>\n<p>I considered using Vercel's <code>VERCEL_ENV</code> at first. That's the \"proper\" Vercel way. But I was already using <code>NODE_ENV</code> elsewhere in my config, so I stuck with that.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span></code></pre>\n<p>This works because:</p>\n<ul>\n<li>Local dev means <code>NODE_ENV=development</code>, so show drafts</li>\n<li>Production build means <code>NODE_ENV=production</code>, so hide drafts</li>\n</ul>\n<p>For the filtering, I added it in <code>/src/lib/posts.ts</code> rather than the Velite config. Here's what that looks like:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { cache } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'react'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { posts } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '#site/content'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// Filter posts at the application level</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> filteredPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> posts.</span><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#F97583\"> =></span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft) </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> posts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// All exported functions use filteredPosts instead of raw posts</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> getPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> cache</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  filteredPosts.</span><span style=\"color:#B392F0\">sort</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">a</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">b</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(b.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">() </span><span style=\"color:#F97583\">-</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(a.date).</span><span style=\"color:#B392F0\">getTime</span><span style=\"color:#E1E4E8\">())</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> getPostBySlug</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> cache</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">slug</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  filteredPosts.</span><span style=\"color:#B392F0\">find</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">p</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> p.slug </span><span style=\"color:#F97583\">===</span><span style=\"color:#E1E4E8\"> slug)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span></span></code></pre>\n<p>I filtered at the application level because it keeps the Velite config simple. I just need the schema field. The filtering logic lives with the rest of the post retrieval code, which felt cleaner to me.</p>\n<h2>How Draft Filtering Works Across Environments</h2>\n<p>Here's how it behaves:</p>\n<ul>\n<li><strong>Local dev</strong> (<code>NODE_ENV=development</code>) means show drafts</li>\n<li><strong>Vercel preview</strong> (<code>NODE_ENV=production</code>) means hide drafts</li>\n<li><strong>Production</strong> (<code>NODE_ENV=production</code>) means hide drafts</li>\n</ul>\n<p>The simple check <code>process.env.NODE_ENV === 'production'</code> handles all cases:</p>\n<ul>\n<li>Local dev gives <code>isProduction = false</code>, so show drafts</li>\n<li>Production build gives <code>isProduction = true</code>, so hide drafts</li>\n</ul>\n<h2>Why I'm Okay With This Now</h2>\n<p>Honestly, defining every field in the schema felt annoying at first. More boilerplate, more config.</p>\n<p>But then I thought about what would happen without it:</p>\n<p><strong>Typo <code>draft: true</code> as <code>darft: true</code>?</strong>\nBuild succeeds. Draft goes live. You don't notice until someone emails you.</p>\n<p><strong>Inconsistent values</strong> like <code>draft: yes</code> or <code>draft: 1</code>?\nNo type checking. Your filter logic breaks silently.</p>\n<p><strong>Rename <code>draft</code> to <code>published</code> in frontmatter</strong> but forget to update code?\nNo build error. Filtering stops working.</p>\n<p>Schema-first means:</p>\n<ul>\n<li>Typos in field names → build fails immediately</li>\n<li>Wrong types → Velite catches it at build time</li>\n<li>Refactoring → TypeScript shows all usage</li>\n</ul>\n<p>The 30 seconds to add a schema field saves hours of \"why isn't this working?\" debugging.</p>\n<h2>What About Vercel Deployments?</h2>\n<p>I initially thought about using <code>VERCEL_ENV</code> since I'm deploying to Vercel. That's what their docs recommend for detecting preview vs production deployments. But <code>NODE_ENV</code> works fine for my use case:</p>\n<ul>\n<li>I don't need separate preview deployments with different draft visibility</li>\n<li><code>NODE_ENV</code> is already set by Vercel in production builds</li>\n<li>I was already using <code>NODE_ENV</code> elsewhere in my config</li>\n</ul>\n<p>If you need draft posts visible in preview deployments but hidden in production, <code>VERCEL_ENV</code> is the way to go:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isProduction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">VERCEL_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// Then use the same filtering logic</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> filteredPosts</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> isProduction </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> posts.</span><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">post</span><span style=\"color:#F97583\"> =></span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">post.draft) </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> posts</span></span></code></pre>\n<p>But for me, <code>NODE_ENV</code> is simpler and works just as well.</p>\n<h2>My Actual Testing Experience</h2>\n<p>I created a test draft post to verify everything worked:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">title</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"Test Draft\"</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">slug</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">test-draft</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">date</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2025-01-03</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">draft</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">This should only appear in dev/preview.</span></span></code></pre>\n<p>First, I ran <code>npm run dev</code>. The draft showed up in my post list. I clicked through to <code>/blog/test-draft</code> and saw the content. Good.</p>\n<p>Then I ran <code>npm run build</code> followed by <code>npm run start</code> to test production behavior.</p>\n<p>Expected: Post appears in list, I can read it.\nActual: Post is gone from the list. <code>/blog/test-draft</code> returns 404.</p>\n<p>Exactly what I wanted. But I wanted to double-check the build output, so I opened <code>.velite/posts.json</code>. Wait. The <code>test-draft</code> entry was still there.</p>\n<p>That's weird. I thought it would be filtered out by Velite?</p>\n<p>Oh right. I'm filtering at the application level in <code>posts.ts</code>, not in the Velite config. Velite builds all posts, including drafts, into <code>.velite/posts.json</code>. Then my application code filters them out when retrieving posts.</p>\n<p>I verified this by checking the RSS feed. In the Velite config's <code>complete</code> callback, I filter out drafts before adding to the feed. So draft posts don't show up in the RSS. But <code>.velite/posts.json</code> still contains everything.</p>\n<p>This is actually fine. The filtering happens where I retrieve posts, so draft posts never reach my components. The build output includes everything, but my app only serves what's appropriate for the environment.</p>\n<h2>What I Learned</h2>\n<p><strong>Velite doesn't auto-pick up frontmatter fields.</strong> You must define them in your schema first.</p>\n<p>This felt like a constraint at first. Now I see it as a safety net:</p>\n<ul>\n<li>Build-time validation catches mistakes</li>\n<li>TypeScript knows what fields exist</li>\n<li>Impossible states become... impossible</li>\n</ul>\n<p>The schema isn't boilerplate. It's the contract between your content and your code.</p>\n<p>That <code>darft: true</code> typo? Can't happen. Velite would ignore the field entirely, and TypeScript would show an error when you try to access <code>post.darft</code>.</p>\n<p>Schema-first design saves future-you from current-you's mistakes.</p>",
            "date_modified": "2026-01-05T00:00:00.000Z",
            "tags": [
                "velite",
                "nextjs"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/premature-optimization-multi-agent-prompts",
            "content_html": "<blockquote>\n<p><strong>Context</strong>: This post assumes you've read <a href=\"/blog/building-multi-agent-blog-review-system\">how I built the multi-agent blog review system</a>. If you haven't, that post explains the architecture before this one breaks it.</p>\n</blockquote>\n<p>I broke my entire blog review system this week trying to beat session limits.</p>\n<p>Not because tokens cost money. Because Claude Code has session limits - I kept hitting the message cap before finishing my work.</p>\n<p>Here's what that means in practice: Claude Code limits how many messages you can send in a session. Each time you ask it something, that's a message. Each time an agent spawns, that's a message. Each file read, each grep search - all messages. When you hit the limit (usually around 100-150 messages depending on context), your workflow just stops mid-task.</p>\n<p>Every agent spawn, every file read, every grep search consumes tokens. When your prompts eat 15,750 tokens before any actual work happens, you burn through your session budget fast.</p>\n<p>So I thought: \"If I can trim these prompts by 1,100 tokens, I'll get more messages per session. More reviews. More work done.\"</p>\n<p>The logic was sound. The execution broke everything.</p>\n<h2>The Setup</h2>\n<p>Context for what I broke:</p>\n<ul>\n<li><strong>System</strong>: Multi-agent blog review system (Orchestrator + 4 specialist critics)</li>\n<li><strong>Agent files</strong>: <code>.claude/agents/blog-authenticity-guardian.md</code>, <code>.claude/agents/blog-structure-editor.md</code>, <code>.claude/agents/blog-skeptical-reader.md</code>, <code>.claude/agents/blog-technical-educator.md</code>, <code>.claude/agents/blog-orchestrator.md</code></li>\n<li><strong>Total prompt size</strong>: ~15,750 tokens across all 5 agents</li>\n<li><strong>Context window</strong>: 200,000 tokens (Claude Sonnet 4.6)</li>\n<li><strong>Session limits</strong>: Hit message cap regularly during multi-agent reviews (typically 100-150 messages per session)</li>\n<li><strong>My brain</strong>: \"If I reduce prompt overhead, I'll get more messages per session\"</li>\n</ul>\n<p>The constraint felt real. Each blog review spawned 4-5 agents, each with 2,000-3,000 token prompts. Add file reads, grep searches, and context - I'd burn 50+ messages per review. When you hit the session limit, your workflow just... stops.</p>\n<h2>The Temptation</h2>\n<p>The optimization seemed obvious. Every agent review consumed messages:</p>\n<ol>\n<li>Orchestrator reads the blog post (1 message)</li>\n<li>Spawns 3 critics in parallel (3 messages)</li>\n<li>Each critic reads files and returns feedback (6-9 messages)</li>\n<li>Spawns Technical Educator for revisions (1 message)</li>\n<li>Educator reads files and proposes changes (3-5 messages)</li>\n<li>Round 2 reviews (another 3-6 messages)</li>\n</ol>\n<p>That's 17-27 messages per blog post review. With 15,750 tokens of prompt overhead per agent, I was front-loading massive context before any actual analysis happened.</p>\n<p>Here's my thinking: Each message I send to Claude can carry some context. If my agent prompts are smaller, each message has more room for actual work - the blog post content, the critic feedback, the revision drafts. Smaller prompts = more work per message = more blog posts reviewed per session.</p>\n<p>The math seemed clear.</p>\n<p>So I started condensing. \"Just remove verbose examples here. Tighten this guidance there. These agents don't need ALL this instruction, right?\"</p>\n<h2>What I Did</h2>\n<p>I opened up the agent prompt files and started cutting:</p>\n<p>From <code>.claude/agents/blog-skeptical-reader.md</code>, I removed:</p>\n<ul>\n<li>The detailed 6-point evaluation framework (Searchability, Specificity, Mental Model Transfer, Cognitive Load, Curse of Knowledge, Journey Documentation)</li>\n<li>The explanations of why each dimension matters</li>\n<li>The example rubrics showing good vs. bad posts</li>\n</ul>\n<p>From <code>.claude/agents/blog-technical-educator.md</code>, I removed:</p>\n<ul>\n<li>The \"Debugging Detective Stories\" framework</li>\n<li>The \"Aha! Moment\" structure guidance</li>\n<li>The Julia Evans and Josh Comeau references (concrete examples to emulate)</li>\n<li>The mental model transfer explanations</li>\n</ul>\n<p>I condensed verbose output format examples into concise headers. I tightened sentences. I deleted \"redundant\" guidance.</p>\n<p>Commit <code>efb9345</code>: Reduced total prompt tokens from ~15,750 to ~14,650.</p>\n<p>I'd saved 1,100 tokens (about 825 words). My optimization was complete.</p>\n<p>The numbers looked great. The system was broken.</p>\n<h2>How I Discovered It</h2>\n<p>Two days after the commit, I ran a blog post through the review system. I'd been working on a debugging story about hydration errors in Next.js 16 - the kind of post my system had been catching gaps in reliably.</p>\n<p>The review process started normally. Orchestrator spawned the three critics in parallel. Authenticity Guardian flagged a preachy opening. Structure Editor suggested reorganizing the mental model section. Skeptical Reader asked for more specific error messages.</p>\n<p>All normal. I spawned the Technical Educator for revisions.</p>\n<p>Then I saw the draft it generated.</p>\n<p>Instead of writing a debugging post that started with the error and showed my journey to resolving the problem, it instead created an entirely different post with a completely unexpected hallucinated structure and heavy tutorial focus. And that was what happened when it worked. At worst it fully hallucinated an entirely different conversation.</p>\n<h2>What \"Hallucinating Structure\" Means</h2>\n<p>When I say the agent was \"hallucinating structure,\" I mean it was inventing a post format that doesn't match my blog's voice at all. Here's the difference:</p>\n<p><strong>What my Technical Educator is supposed to generate</strong> (from <code>.claude/agents/blog-technical-educator.md</code> before my optimization):</p>\n<pre><code>Your posts are debugging detective stories. Start with the error message.\nShow what you tried that didn't work. Document the \"aha!\" moment. Then\nexplain the mental model that makes it obvious in hindsight. Think: Julia\nEvans blog posts, not MDN documentation.\n\nStructure:\n1. Start with the problem (relatable, specific)\n2. Show the debugging journey (wrong turns and all)\n3. Explain the mental model (why it works, not just how)\n4. End with the solution (what finally worked)\n\nUse conversational tone. Be honest about confusion. No \"in this post,\nI'll show you how\" or other tutorial language.\n</code></pre>\n<p><strong>What it generated after my optimization</strong> (what I actually saw):</p>\n<p>Dramatic section headers like \"Breaking Point #1\" and \"Breaking Point #2\". Content marketing structure where each section is a \"problem\" followed by an \"explanation.\" Formal, distant voice (\"The primary issue manifests when...\"). No confusion shown, no wrong turns documented, no debugging story - just clean instruction.</p>\n<p>That's hallucinating structure: the agent invented a format that doesn't exist in my blog's voice because I'd removed the guidance that defined what my voice actually is.</p>\n<h2>What Went Wrong</h2>\n<p>I looked at the actual changes I made to figure out what I'd removed.</p>\n<h3>The Missing Evaluation Dimensions</h3>\n<p>Here's what my Skeptical Reader prompt looked like before my optimization:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Evaluation Dimensions</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">You evaluate posts against 6 dimensions:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">1.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Searchability**</span><span style=\"color:#E1E4E8\">: Does the title include specific phrases people Google?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ \"Error: Cannot read property 'map' of undefined\" (searchable)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ \"Understanding Async/Await in JavaScript\" (generic)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">2.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Specificity**</span><span style=\"color:#E1E4E8\">: Are version numbers, actual error messages, and real code included?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ \"Next.js 16.1.1\", \"Cannot read property 'map' of undefined\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ \"Next.js 16\", \"an error occurred\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">3.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Mental Model Transfer**</span><span style=\"color:#E1E4E8\">: Is the WHY explained before the HOW?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ \"I finally understood this was a timing issue...\" (explains why)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ \"Add async/await to handle promises\" (just says how)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">4.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Cognitive Load**</span><span style=\"color:#E1E4E8\">: Does complexity progress gradually?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ Simple version first, then nuance, then edge cases</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ Jump straight to complex implementation</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">5.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Curse of Knowledge**</span><span style=\"color:#E1E4E8\">: Would past-Luke understand this?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ \"I thought X, but actually Y because...\" (bridges gap)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ \"This is obvious...\" (assumes knowledge)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">6.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Journey Documentation**</span><span style=\"color:#E1E4E8\">: Is the debugging process shown?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ \"I tried X, which didn't work. Then I tried Y...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ Just the solution, no process</span></span></code></pre>\n<p>After my optimization, I'd condensed this to:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Evaluation Dimensions</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Check for:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Specificity (versions, error messages, real code)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Examples for every concept</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Clear explanations</span></span></code></pre>\n<p>Cognitive Load and Curse of Knowledge weren't just combined - they were gone. Searchability was missing. Journey Documentation had vanished. The agent couldn't catch those failure modes anymore because I'd deleted the concepts from its prompt entirely.</p>\n<h3>The Removed Framework Guidance</h3>\n<p>Here's the actual Technical Educator framework I removed:</p>\n<p><strong>Before (commit 56ad54a - worked):</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Your Mission</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">You create blog content that documents Luke's journey. You write for \"past Luke\" - the version of him from 3-6 months ago who was struggling with the same problems. Your posts are specific and story-driven. Maximum helpfulness comes from sharing Luke's actual experience in detail, not from prescriptive advice.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## What Journey Posts Actually Look Like</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Your posts are debugging detective stories. Start with the error message. Show what you tried that didn't work. Document the \"aha!\" moment. Then explain the mental model that makes it obvious in hindsight.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Think: Julia Evans blog posts, not MDN documentation.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Structure Template</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">I kept hitting [specific error]. Here's the message:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[actual error message]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">I tried:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">1.</span><span style=\"color:#E1E4E8\"> [first thing I tried] - didn't work because [</span><span style=\"color:#DBEDFF;text-decoration:underline\">reason</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">2.</span><span style=\"color:#E1E4E8\"> [second thing I tried] - didn't work because [</span><span style=\"color:#DBEDFF;text-decoration:underline\">reason</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">What finally worked: [</span><span style=\"color:#DBEDFF;text-decoration:underline\">solution</span><span style=\"color:#E1E4E8\">].</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Here's the mental model: [explanation of why it works]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">(Past-me from 6 months ago would have never caught this.)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Mental Model Transfer</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Always explain WHY before HOW. Build conceptual understanding first, then show implementation.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Examples:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> ❌ \"Add async/await to your function\" (just how)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> ✅ \"This is a timing issue. The data arrives asynchronously, so we need to wait for it. Here's how: add async/await...\" (why first, then how)</span></span></code></pre>\n<p><strong>After (commit efb9345 - broken):</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Your Mission</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Create blog posts that document Luke's journey. Write for past-Luke who was struggling with similar problems.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Post Structure</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Start with the error message. Show what you tried. Explain what worked.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Mental Model Transfer</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Explain why before how.</span></span></code></pre>\n<p>I removed:</p>\n<ul>\n<li>\"Debugging detective stories\" (the framing)</li>\n<li>\"Aha! moment\" (the emotional arc)</li>\n<li>\"Mental model that makes it obvious\" (the purpose)</li>\n<li>Julia Evans reference (the concrete example to emulate)</li>\n<li>The entire structure template with examples</li>\n<li>The specific WHY-before-HOW examples</li>\n<li>The conversational tone guidance</li>\n</ul>\n<p>I kept the surface instruction (\"show debugging journey\") but deleted all the guidance about <em>how</em> and <em>why</em>.</p>\n<h3>The Defensive Repetition</h3>\n<p>The funniest part? I'd actually already tried this optimization.</p>\n<p>Commit <code>1b076ef</code> from weeks earlier: \"Edits to multi agent blog post generation to try and reduce hallucination.\"</p>\n<p>I'd noticed the agents drifting. I'd <em>added back</em> framework guidance and evaluation dimensions to fix it. Then I deleted them again trying to save tokens.</p>\n<p>I'd literally undone my own fix because I'd forgotten why I made it.</p>\n<h2>What Actually Worked</h2>\n<p>After rolling back, I did a more surgical optimization:</p>\n<p>I condensed only the output format templates (verbose example posts → concise headers like \"example title / example slug\"). I kept every single evaluation dimension. I kept all the framework guidance about debugging stories and mental models.</p>\n<p>Savings: ~400 tokens instead of 1,100.\nHallucinations: Zero.\nTime wasted: 5 hours I'll never get back.</p>\n<p>The system works again. My agents are catching gaps in posts, preserving my voice, and helping me improve. I just didn't save as many tokens as I wanted.</p>\n<h2>So, Three Things I'm Carrying Forward</h2>\n<p>Prompt tokens are not like code bloat. In regular code, every unused import or redundant function adds maintenance burden. But in AI prompts, \"redundant\" guidance is often the difference between reliable behavior and genre drift. My agents needed that repeated emphasis on \"debugging detective stories\" and \"learn in public.\" The repetition creates a stronger pattern in the context.</p>\n<p>Session limits aren't always solved by prompt optimization. I was trying to squeeze more work into limited sessions by trimming prompts. But the real constraint wasn't prompt tokens - it was the number of agent spawns and file operations per review. That and the obscenely limited session time that Anthropic give you. The better approach would have been fewer review rounds, caching responses, or batching multiple posts in one session.</p>\n<p>Trust your systems. I had a working multi-agent review system. It caught gaps in my posts. It preserved my voice. It helped me improve. Then I broke it trying to make it \"more efficient.\" The real efficiency would have been running more reviews with the working system, not optimizing away the safeguards that made it work.</p>",
            "url": "https://lukemanning.ie/blog/premature-optimization-multi-agent-prompts",
            "title": "I Broke My Blog Review System Trying to Beat Session Limits",
            "summary": "<blockquote>\n<p><strong>Context</strong>: This post assumes you've read <a href=\"/blog/building-multi-agent-blog-review-system\">how I built the multi-agent blog review system</a>. If you haven't, that post explains the architecture before this one breaks it.</p>\n</blockquote>\n<p>I broke my entire blog review system this week trying to beat session limits.</p>\n<p>Not because tokens cost money. Because Claude Code has session limits - I kept hitting the message cap before finishing my work.</p>\n<p>Here's what that means in practice: Claude Code limits how many messages you can send in a session. Each time you ask it something, that's a message. Each time an agent spawns, that's a message. Each file read, each grep search - all messages. When you hit the limit (usually around 100-150 messages depending on context), your workflow just stops mid-task.</p>\n<p>Every agent spawn, every file read, every grep search consumes tokens. When your prompts eat 15,750 tokens before any actual work happens, you burn through your session budget fast.</p>\n<p>So I thought: \"If I can trim these prompts by 1,100 tokens, I'll get more messages per session. More reviews. More work done.\"</p>\n<p>The logic was sound. The execution broke everything.</p>\n<h2>The Setup</h2>\n<p>Context for what I broke:</p>\n<ul>\n<li><strong>System</strong>: Multi-agent blog review system (Orchestrator + 4 specialist critics)</li>\n<li><strong>Agent files</strong>: <code>.claude/agents/blog-authenticity-guardian.md</code>, <code>.claude/agents/blog-structure-editor.md</code>, <code>.claude/agents/blog-skeptical-reader.md</code>, <code>.claude/agents/blog-technical-educator.md</code>, <code>.claude/agents/blog-orchestrator.md</code></li>\n<li><strong>Total prompt size</strong>: ~15,750 tokens across all 5 agents</li>\n<li><strong>Context window</strong>: 200,000 tokens (Claude Sonnet 4.6)</li>\n<li><strong>Session limits</strong>: Hit message cap regularly during multi-agent reviews (typically 100-150 messages per session)</li>\n<li><strong>My brain</strong>: \"If I reduce prompt overhead, I'll get more messages per session\"</li>\n</ul>\n<p>The constraint felt real. Each blog review spawned 4-5 agents, each with 2,000-3,000 token prompts. Add file reads, grep searches, and context - I'd burn 50+ messages per review. When you hit the session limit, your workflow just... stops.</p>\n<h2>The Temptation</h2>\n<p>The optimization seemed obvious. Every agent review consumed messages:</p>\n<ol>\n<li>Orchestrator reads the blog post (1 message)</li>\n<li>Spawns 3 critics in parallel (3 messages)</li>\n<li>Each critic reads files and returns feedback (6-9 messages)</li>\n<li>Spawns Technical Educator for revisions (1 message)</li>\n<li>Educator reads files and proposes changes (3-5 messages)</li>\n<li>Round 2 reviews (another 3-6 messages)</li>\n</ol>\n<p>That's 17-27 messages per blog post review. With 15,750 tokens of prompt overhead per agent, I was front-loading massive context before any actual analysis happened.</p>\n<p>Here's my thinking: Each message I send to Claude can carry some context. If my agent prompts are smaller, each message has more room for actual work - the blog post content, the critic feedback, the revision drafts. Smaller prompts = more work per message = more blog posts reviewed per session.</p>\n<p>The math seemed clear.</p>\n<p>So I started condensing. \"Just remove verbose examples here. Tighten this guidance there. These agents don't need ALL this instruction, right?\"</p>\n<h2>What I Did</h2>\n<p>I opened up the agent prompt files and started cutting:</p>\n<p>From <code>.claude/agents/blog-skeptical-reader.md</code>, I removed:</p>\n<ul>\n<li>The detailed 6-point evaluation framework (Searchability, Specificity, Mental Model Transfer, Cognitive Load, Curse of Knowledge, Journey Documentation)</li>\n<li>The explanations of why each dimension matters</li>\n<li>The example rubrics showing good vs. bad posts</li>\n</ul>\n<p>From <code>.claude/agents/blog-technical-educator.md</code>, I removed:</p>\n<ul>\n<li>The \"Debugging Detective Stories\" framework</li>\n<li>The \"Aha! Moment\" structure guidance</li>\n<li>The Julia Evans and Josh Comeau references (concrete examples to emulate)</li>\n<li>The mental model transfer explanations</li>\n</ul>\n<p>I condensed verbose output format examples into concise headers. I tightened sentences. I deleted \"redundant\" guidance.</p>\n<p>Commit <code>efb9345</code>: Reduced total prompt tokens from ~15,750 to ~14,650.</p>\n<p>I'd saved 1,100 tokens (about 825 words). My optimization was complete.</p>\n<p>The numbers looked great. The system was broken.</p>\n<h2>How I Discovered It</h2>\n<p>Two days after the commit, I ran a blog post through the review system. I'd been working on a debugging story about hydration errors in Next.js 16 - the kind of post my system had been catching gaps in reliably.</p>\n<p>The review process started normally. Orchestrator spawned the three critics in parallel. Authenticity Guardian flagged a preachy opening. Structure Editor suggested reorganizing the mental model section. Skeptical Reader asked for more specific error messages.</p>\n<p>All normal. I spawned the Technical Educator for revisions.</p>\n<p>Then I saw the draft it generated.</p>\n<p>Instead of writing a debugging post that started with the error and showed my journey to resolving the problem, it instead created an entirely different post with a completely unexpected hallucinated structure and heavy tutorial focus. And that was what happened when it worked. At worst it fully hallucinated an entirely different conversation.</p>\n<h2>What \"Hallucinating Structure\" Means</h2>\n<p>When I say the agent was \"hallucinating structure,\" I mean it was inventing a post format that doesn't match my blog's voice at all. Here's the difference:</p>\n<p><strong>What my Technical Educator is supposed to generate</strong> (from <code>.claude/agents/blog-technical-educator.md</code> before my optimization):</p>\n<pre><code>Your posts are debugging detective stories. Start with the error message.\nShow what you tried that didn't work. Document the \"aha!\" moment. Then\nexplain the mental model that makes it obvious in hindsight. Think: Julia\nEvans blog posts, not MDN documentation.\n\nStructure:\n1. Start with the problem (relatable, specific)\n2. Show the debugging journey (wrong turns and all)\n3. Explain the mental model (why it works, not just how)\n4. End with the solution (what finally worked)\n\nUse conversational tone. Be honest about confusion. No \"in this post,\nI'll show you how\" or other tutorial language.\n</code></pre>\n<p><strong>What it generated after my optimization</strong> (what I actually saw):</p>\n<p>Dramatic section headers like \"Breaking Point #1\" and \"Breaking Point #2\". Content marketing structure where each section is a \"problem\" followed by an \"explanation.\" Formal, distant voice (\"The primary issue manifests when...\"). No confusion shown, no wrong turns documented, no debugging story - just clean instruction.</p>\n<p>That's hallucinating structure: the agent invented a format that doesn't exist in my blog's voice because I'd removed the guidance that defined what my voice actually is.</p>\n<h2>What Went Wrong</h2>\n<p>I looked at the actual changes I made to figure out what I'd removed.</p>\n<h3>The Missing Evaluation Dimensions</h3>\n<p>Here's what my Skeptical Reader prompt looked like before my optimization:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Evaluation Dimensions</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">You evaluate posts against 6 dimensions:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">1.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Searchability**</span><span style=\"color:#E1E4E8\">: Does the title include specific phrases people Google?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ \"Error: Cannot read property 'map' of undefined\" (searchable)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ \"Understanding Async/Await in JavaScript\" (generic)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">2.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Specificity**</span><span style=\"color:#E1E4E8\">: Are version numbers, actual error messages, and real code included?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ \"Next.js 16.1.1\", \"Cannot read property 'map' of undefined\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ \"Next.js 16\", \"an error occurred\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">3.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Mental Model Transfer**</span><span style=\"color:#E1E4E8\">: Is the WHY explained before the HOW?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ \"I finally understood this was a timing issue...\" (explains why)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ \"Add async/await to handle promises\" (just says how)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">4.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Cognitive Load**</span><span style=\"color:#E1E4E8\">: Does complexity progress gradually?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ Simple version first, then nuance, then edge cases</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ Jump straight to complex implementation</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">5.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Curse of Knowledge**</span><span style=\"color:#E1E4E8\">: Would past-Luke understand this?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ \"I thought X, but actually Y because...\" (bridges gap)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ \"This is obvious...\" (assumes knowledge)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">6.</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Journey Documentation**</span><span style=\"color:#E1E4E8\">: Is the debugging process shown?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ✅ \"I tried X, which didn't work. Then I tried Y...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> ❌ Just the solution, no process</span></span></code></pre>\n<p>After my optimization, I'd condensed this to:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Evaluation Dimensions</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Check for:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Specificity (versions, error messages, real code)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Examples for every concept</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Clear explanations</span></span></code></pre>\n<p>Cognitive Load and Curse of Knowledge weren't just combined - they were gone. Searchability was missing. Journey Documentation had vanished. The agent couldn't catch those failure modes anymore because I'd deleted the concepts from its prompt entirely.</p>\n<h3>The Removed Framework Guidance</h3>\n<p>Here's the actual Technical Educator framework I removed:</p>\n<p><strong>Before (commit 56ad54a - worked):</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Your Mission</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">You create blog content that documents Luke's journey. You write for \"past Luke\" - the version of him from 3-6 months ago who was struggling with the same problems. Your posts are specific and story-driven. Maximum helpfulness comes from sharing Luke's actual experience in detail, not from prescriptive advice.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## What Journey Posts Actually Look Like</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Your posts are debugging detective stories. Start with the error message. Show what you tried that didn't work. Document the \"aha!\" moment. Then explain the mental model that makes it obvious in hindsight.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Think: Julia Evans blog posts, not MDN documentation.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Structure Template</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">I kept hitting [specific error]. Here's the message:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[actual error message]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">I tried:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">1.</span><span style=\"color:#E1E4E8\"> [first thing I tried] - didn't work because [</span><span style=\"color:#DBEDFF;text-decoration:underline\">reason</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">2.</span><span style=\"color:#E1E4E8\"> [second thing I tried] - didn't work because [</span><span style=\"color:#DBEDFF;text-decoration:underline\">reason</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">What finally worked: [</span><span style=\"color:#DBEDFF;text-decoration:underline\">solution</span><span style=\"color:#E1E4E8\">].</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Here's the mental model: [explanation of why it works]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">(Past-me from 6 months ago would have never caught this.)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Mental Model Transfer</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Always explain WHY before HOW. Build conceptual understanding first, then show implementation.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Examples:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> ❌ \"Add async/await to your function\" (just how)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> ✅ \"This is a timing issue. The data arrives asynchronously, so we need to wait for it. Here's how: add async/await...\" (why first, then how)</span></span></code></pre>\n<p><strong>After (commit efb9345 - broken):</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Your Mission</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Create blog posts that document Luke's journey. Write for past-Luke who was struggling with similar problems.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Post Structure</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Start with the error message. Show what you tried. Explain what worked.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Mental Model Transfer</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Explain why before how.</span></span></code></pre>\n<p>I removed:</p>\n<ul>\n<li>\"Debugging detective stories\" (the framing)</li>\n<li>\"Aha! moment\" (the emotional arc)</li>\n<li>\"Mental model that makes it obvious\" (the purpose)</li>\n<li>Julia Evans reference (the concrete example to emulate)</li>\n<li>The entire structure template with examples</li>\n<li>The specific WHY-before-HOW examples</li>\n<li>The conversational tone guidance</li>\n</ul>\n<p>I kept the surface instruction (\"show debugging journey\") but deleted all the guidance about <em>how</em> and <em>why</em>.</p>\n<h3>The Defensive Repetition</h3>\n<p>The funniest part? I'd actually already tried this optimization.</p>\n<p>Commit <code>1b076ef</code> from weeks earlier: \"Edits to multi agent blog post generation to try and reduce hallucination.\"</p>\n<p>I'd noticed the agents drifting. I'd <em>added back</em> framework guidance and evaluation dimensions to fix it. Then I deleted them again trying to save tokens.</p>\n<p>I'd literally undone my own fix because I'd forgotten why I made it.</p>\n<h2>What Actually Worked</h2>\n<p>After rolling back, I did a more surgical optimization:</p>\n<p>I condensed only the output format templates (verbose example posts → concise headers like \"example title / example slug\"). I kept every single evaluation dimension. I kept all the framework guidance about debugging stories and mental models.</p>\n<p>Savings: ~400 tokens instead of 1,100.\nHallucinations: Zero.\nTime wasted: 5 hours I'll never get back.</p>\n<p>The system works again. My agents are catching gaps in posts, preserving my voice, and helping me improve. I just didn't save as many tokens as I wanted.</p>\n<h2>So, Three Things I'm Carrying Forward</h2>\n<p>Prompt tokens are not like code bloat. In regular code, every unused import or redundant function adds maintenance burden. But in AI prompts, \"redundant\" guidance is often the difference between reliable behavior and genre drift. My agents needed that repeated emphasis on \"debugging detective stories\" and \"learn in public.\" The repetition creates a stronger pattern in the context.</p>\n<p>Session limits aren't always solved by prompt optimization. I was trying to squeeze more work into limited sessions by trimming prompts. But the real constraint wasn't prompt tokens - it was the number of agent spawns and file operations per review. That and the obscenely limited session time that Anthropic give you. The better approach would have been fewer review rounds, caching responses, or batching multiple posts in one session.</p>\n<p>Trust your systems. I had a working multi-agent review system. It caught gaps in my posts. It preserved my voice. It helped me improve. Then I broke it trying to make it \"more efficient.\" The real efficiency would have been running more reviews with the working system, not optimizing away the safeguards that made it work.</p>",
            "date_modified": "2026-01-04T00:00:00.000Z",
            "tags": [
                "ai"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/claude-code-accept-edits-claude-directory",
            "content_html": "<p>I had auto-accept edits turned on in Claude Code. The status line said so: <code>⏵ accept edits on (shift+tab to cycle)</code>.</p>\n<p>But Claude kept asking me to approve edits to <code>.claude/agents/blog-structure-editor/AGENT.md</code>.</p>\n<p>Every. Single. Time.</p>\n<p>I was working on creating and improving agent definitions with assistance from Claude, and the agents were in <code>~/.claude/agents/</code>. This is the same kind of agent configuration work I later tackled more systematically — see <a href=\"/blog/opencode-nested-slash-commands-architecture\">how I consolidated OpenCode slash commands</a> for the full picture.</p>\n<p>I'd hit Shift+Tab a few times to cycle through the modes, confirm I was back on \"accept edits on,\" and try again. Still prompted. What was going on?</p>\n<h2>Testing the Theory</h2>\n<p>After quite a while of this happening, I decided to test it systematically.</p>\n<p>The exact prompt I kept seeing was:</p>\n<pre><code>Accept edits to .claude/agents/blog-structure-editor/AGENT.md? (y/n)\n</code></pre>\n<p>Even though my status line clearly showed <code>⏵ accept edits on (shift+tab to cycle)</code>.</p>\n<p>First theory: maybe I wasn't actually in acceptEdits mode? But the status line was clear. And when I asked Claude to edit <code>CLAUDE.md</code> (my project instructions), it went through without prompting. So acceptEdits <em>was</em> working.</p>\n<p>Just... not for files in <code>.claude/</code>.</p>\n<p>To confirm this wasn't just me, I asked Claude to try editing the same file:</p>\n<blockquote>\n<p>\"Try editing <code>.claude/agents/blog-skeptical-reader/AGENT.md</code> - let's see if you get prompted too.\"</p>\n</blockquote>\n<p>Claude got prompted too.</p>\n<p>Then I tested it systematically myself in different directories:</p>\n<p><strong>In <code>/home/luke/projects/my-app/</code></strong> - No prompt (auto-accept working)\n<strong>In <code>/home/luke/.claude/agents/</code></strong> - Got prompted (auto-accept disabled)</p>\n<p>I noticed the pattern immediately—only the agents directory was prompting me. So it wasn't my settings. It wasn't a glitch. Something about the <code>.claude/</code> directory was different.</p>\n<h2>The Aha Moment</h2>\n<p>This wasn't a bug. It was a security feature.</p>\n<p>Think about what lives in <code>.claude/</code>:</p>\n<ul>\n<li><strong>Agent definitions</strong> - the system prompts that define how Claude behaves</li>\n<li><strong>Tool permissions</strong> - what Claude can and can't do on your system</li>\n<li><strong>Commands and skills</strong> - custom workflows you've defined</li>\n<li><strong>Settings</strong> - your preferences and configurations</li>\n</ul>\n<p>These aren't just any files. These are the files that control <em>how Claude Code works</em>.</p>\n<p>If Claude could silently edit them without prompting, even in auto-accept mode, you could accidentally grant broader permissions, change agent behavior, or modify critical settings without realizing it.</p>\n<h2>Why This Design Makes Sense</h2>\n<p>Auto-accept mode is great for the 95% of edits you want to flow through quickly:</p>\n<ul>\n<li>Code files you're actively working on</li>\n<li>Documentation updates</li>\n<li>Test file changes</li>\n<li>Configuration tweaks</li>\n</ul>\n<p>But <code>.claude/</code> files are in a different category. They're meta-level - they affect the tool itself, not just your project.</p>\n<p>Requiring explicit approval for these edits is like how <code>sudo</code> requires your password even if you're already logged in. It's a speed bump that makes you pause and think: \"Do I really want to make this change?\"</p>\n<h2>Overriding If You Really Want To</h2>\n<p>If you trust Claude completely with your agent definitions and want to skip the prompts, you can add an explicit allow rule:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"permissions\"</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    \"allow\"</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"Edit(.claude/**)\"</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>This tells Claude: \"Yes, I know what I'm doing. Auto-accept edits to <code>.claude/</code> files too.\"</p>\n<p><strong>After understanding this:</strong> When I edit files in any directory EXCEPT <code>.claude/agents/</code>, they go through immediately with no approval prompt. But when I edit files in the agents directory, I still get prompted—which is exactly what I want for safety.</p>\n<p>I'm keeping the default behavior though. The prompts are a feature, not a bug.</p>\n<p><strong>What I see now:</strong> When I edit files outside <code>.claude/agents/</code>, the changes happen immediately without any prompt. When I edit files inside the agents directory, I still get asked to approve—which is exactly what I want.</p>\n<h2>What I Learned</h2>\n<p>When a tool doesn't work the way you expect, there's usually a reason. Sometimes it's a bug. Sometimes it's a misunderstanding. And sometimes - like this - it's intentional design that makes sense once you understand the why.</p>\n<p>Claude Code's auto-accept mode is smart enough to know:</p>\n<ul>\n<li>Most file edits are safe to auto-approve (your project code)</li>\n<li>Some file edits need a human check (configuration that affects the tool itself)</li>\n</ul>\n<p>I went from \"Why isn't this working?\" to \"Oh, that's actually really thoughtful.\"</p>\n<p>This experience shifted how I think about unexpected behavior. Now when something seems broken, I pause and ask: what would break if it worked the way I expected? Maybe the friction exists for a reason.</p>\n<hr>\n<p><strong>TL;DR</strong>: Claude Code's auto-accept mode skips <code>.claude/</code> files even when it's on. Now that I get why—I realized silent edits to my agent definitions would be dangerous—I'm keeping the default.</p>",
            "url": "https://lukemanning.ie/blog/claude-code-accept-edits-claude-directory",
            "title": "Why Claude Code Still Prompts for Edits to .claude/ Files (Even with Auto-Accept On)",
            "summary": "<p>I had auto-accept edits turned on in Claude Code. The status line said so: <code>⏵ accept edits on (shift+tab to cycle)</code>.</p>\n<p>But Claude kept asking me to approve edits to <code>.claude/agents/blog-structure-editor/AGENT.md</code>.</p>\n<p>Every. Single. Time.</p>\n<p>I was working on creating and improving agent definitions with assistance from Claude, and the agents were in <code>~/.claude/agents/</code>. This is the same kind of agent configuration work I later tackled more systematically — see <a href=\"/blog/opencode-nested-slash-commands-architecture\">how I consolidated OpenCode slash commands</a> for the full picture.</p>\n<p>I'd hit Shift+Tab a few times to cycle through the modes, confirm I was back on \"accept edits on,\" and try again. Still prompted. What was going on?</p>\n<h2>Testing the Theory</h2>\n<p>After quite a while of this happening, I decided to test it systematically.</p>\n<p>The exact prompt I kept seeing was:</p>\n<pre><code>Accept edits to .claude/agents/blog-structure-editor/AGENT.md? (y/n)\n</code></pre>\n<p>Even though my status line clearly showed <code>⏵ accept edits on (shift+tab to cycle)</code>.</p>\n<p>First theory: maybe I wasn't actually in acceptEdits mode? But the status line was clear. And when I asked Claude to edit <code>CLAUDE.md</code> (my project instructions), it went through without prompting. So acceptEdits <em>was</em> working.</p>\n<p>Just... not for files in <code>.claude/</code>.</p>\n<p>To confirm this wasn't just me, I asked Claude to try editing the same file:</p>\n<blockquote>\n<p>\"Try editing <code>.claude/agents/blog-skeptical-reader/AGENT.md</code> - let's see if you get prompted too.\"</p>\n</blockquote>\n<p>Claude got prompted too.</p>\n<p>Then I tested it systematically myself in different directories:</p>\n<p><strong>In <code>/home/luke/projects/my-app/</code></strong> - No prompt (auto-accept working)\n<strong>In <code>/home/luke/.claude/agents/</code></strong> - Got prompted (auto-accept disabled)</p>\n<p>I noticed the pattern immediately—only the agents directory was prompting me. So it wasn't my settings. It wasn't a glitch. Something about the <code>.claude/</code> directory was different.</p>\n<h2>The Aha Moment</h2>\n<p>This wasn't a bug. It was a security feature.</p>\n<p>Think about what lives in <code>.claude/</code>:</p>\n<ul>\n<li><strong>Agent definitions</strong> - the system prompts that define how Claude behaves</li>\n<li><strong>Tool permissions</strong> - what Claude can and can't do on your system</li>\n<li><strong>Commands and skills</strong> - custom workflows you've defined</li>\n<li><strong>Settings</strong> - your preferences and configurations</li>\n</ul>\n<p>These aren't just any files. These are the files that control <em>how Claude Code works</em>.</p>\n<p>If Claude could silently edit them without prompting, even in auto-accept mode, you could accidentally grant broader permissions, change agent behavior, or modify critical settings without realizing it.</p>\n<h2>Why This Design Makes Sense</h2>\n<p>Auto-accept mode is great for the 95% of edits you want to flow through quickly:</p>\n<ul>\n<li>Code files you're actively working on</li>\n<li>Documentation updates</li>\n<li>Test file changes</li>\n<li>Configuration tweaks</li>\n</ul>\n<p>But <code>.claude/</code> files are in a different category. They're meta-level - they affect the tool itself, not just your project.</p>\n<p>Requiring explicit approval for these edits is like how <code>sudo</code> requires your password even if you're already logged in. It's a speed bump that makes you pause and think: \"Do I really want to make this change?\"</p>\n<h2>Overriding If You Really Want To</h2>\n<p>If you trust Claude completely with your agent definitions and want to skip the prompts, you can add an explicit allow rule:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"permissions\"</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    \"allow\"</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"Edit(.claude/**)\"</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>This tells Claude: \"Yes, I know what I'm doing. Auto-accept edits to <code>.claude/</code> files too.\"</p>\n<p><strong>After understanding this:</strong> When I edit files in any directory EXCEPT <code>.claude/agents/</code>, they go through immediately with no approval prompt. But when I edit files in the agents directory, I still get prompted—which is exactly what I want for safety.</p>\n<p>I'm keeping the default behavior though. The prompts are a feature, not a bug.</p>\n<p><strong>What I see now:</strong> When I edit files outside <code>.claude/agents/</code>, the changes happen immediately without any prompt. When I edit files inside the agents directory, I still get asked to approve—which is exactly what I want.</p>\n<h2>What I Learned</h2>\n<p>When a tool doesn't work the way you expect, there's usually a reason. Sometimes it's a bug. Sometimes it's a misunderstanding. And sometimes - like this - it's intentional design that makes sense once you understand the why.</p>\n<p>Claude Code's auto-accept mode is smart enough to know:</p>\n<ul>\n<li>Most file edits are safe to auto-approve (your project code)</li>\n<li>Some file edits need a human check (configuration that affects the tool itself)</li>\n</ul>\n<p>I went from \"Why isn't this working?\" to \"Oh, that's actually really thoughtful.\"</p>\n<p>This experience shifted how I think about unexpected behavior. Now when something seems broken, I pause and ask: what would break if it worked the way I expected? Maybe the friction exists for a reason.</p>\n<hr>\n<p><strong>TL;DR</strong>: Claude Code's auto-accept mode skips <code>.claude/</code> files even when it's on. Now that I get why—I realized silent edits to my agent definitions would be dangerous—I'm keeping the default.</p>",
            "date_modified": "2026-01-03T00:00:00.000Z",
            "tags": [
                "claude-code"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/ai-debates-itself-to-review-my-ai-instructions",
            "content_html": "<p>I have a confession: I built an AI system to not only review my AI instructions, but to <em>argue with each other</em> about what should and shouldn't be in there.</p>\n<p>I'd been using Claude Code with the sub-agent orchestration feature for a few months when I realized something. (These days I've moved most of this workflow to OpenCode — see <a href=\"/blog/opencode-nested-slash-commands-architecture\">how I consolidated my slash commands</a> for the architectural details.)</p>\n<p>Let me explain how I got here, because the journey from \"I should update this file more often\" to \"let's orchestrate a formal debate between specialized sub-agents\" is… well, it's a journey.</p>\n<h2>The Problem: Context Drift</h2>\n<p>If you're using Claude Code (or any AI coding assistant), you've probably discovered <code>CLAUDE.md</code>, that special instructions file where you tell Claude about your project structure, conventions, and domain knowledge.</p>\n<p>It's incredibly powerful when it's accurate, but it as recent studies have shown it can actually become a hindrance when it contains outdated or stale information. Theo did a great YouTube video covering this concept here:\n<a href=\"https://www.youtube.com/watch?v=GcNu6wrLTJc\">Delete your CLAUDE.md (and your AGENT.md too)</a></p>\n<p>Here's what would typically happen:</p>\n<ol>\n<li>Start a new feature and make a significant update to my project</li>\n<li>Think \"I should add this to CLAUDE.md so Claude knows this\"</li>\n<li>Get distracted by actual work</li>\n<li>Forget to update it</li>\n<li>Repeat</li>\n</ol>\n<p>My CLAUDE.md was missing critical context, leading to repetitive conversations where I'd explain the same project structure or conventions over and over. It also often had stale context, because either the directory structure had changed a bit, or some relevant files had moved.</p>\n<h2>The Naive Solution: Just Ask Claude</h2>\n<p>My first thought was simple: \"Hey Claude, after we finish this conversation, can you suggest updates to my CLAUDE.md file?\"</p>\n<p>Guess what happened?</p>\n<p><strong>Bloat. Massive, uncontrolled bloat.</strong></p>\n<p>Every conversation ended with Claude suggesting 5-10 new sections to add. Here's an example from one session:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">Claude suggested adding:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"When working with components in src/components/ui, always use...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"For API routes in src/app/api, remember to...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"The build process uses Next.js static exports...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"Color palette is defined in tailwind.config.ts...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"Error handling should follow this pattern...\"</span></span></code></pre>\n<p>None of the suggestions included <em>removing</em> anything. Within a few iterations, I would have had a 1,000-line instruction file covering every edge case we'd ever discussed.</p>\n<p>The problem with asking a single AI agent to improve documentation is the same problem humans have: <strong>additive bias</strong>. It's psychologically easier to add information than to delete it. We don't want to lose potentially useful context, so we keep stacking it on.</p>\n<p>But an instruction file that tries to document everything ends up being too long for Claude to effectively use. You hit context limits, instructions contradict each other, and the signal-to-noise ratio tanks.</p>\n<h2>The Insight: I Need a Critic, Not Just a Suggester</h2>\n<p>The breakthrough came when I realized what was missing: <strong>adversarial review</strong>.</p>\n<p>In code reviews, we don't just ask \"what else could we add?\" We ask \"what can we remove?\" and \"is this really necessary?\" That pushback is what keeps codebases maintainable.</p>\n<p>That's when I designed the multi-agent debate system.</p>\n<h2>The Architecture: Orchestrated Debate</h2>\n<p>Here's how it works when I run <code>/improve-claude-md</code>:</p>\n<h3>The Three Roles</h3>\n<p>There are three agents in this system, and I gave each one a specific personality:</p>\n<p><strong>The Orchestrator</strong> runs the show. It reviews our conversation, reads CLAUDE.md, then spawns the other two agents. Its job is to manage the debate and give me a final report.</p>\n<p><strong>The Improver</strong> is the optimist. It looks for patterns where Claude got stuck and proposes fixes. Here's the key: it also proposes deletions, which fights that additive bias I mentioned earlier.</p>\n<p><strong>The Critic</strong> is... well, a critic. It challenges everything. \"Is this <em>really</em> needed?\" it asks. \"Will this still be relevant in two weeks?\" It's the adversarial voice that keeps things lean.</p>\n<h3>The Debate Process</h3>\n<p><strong>Round 1: Initial Proposals</strong></p>\n<ul>\n<li>Improver suggests 3-5 high-priority changes</li>\n<li>Critic challenges each one: \"Is this <em>really</em> necessary?\"</li>\n</ul>\n<p><strong>Round 2: Defense &#x26; Refinement</strong></p>\n<ul>\n<li>Improver responds with evidence from the conversation</li>\n<li>Critic either approves or maintains objections</li>\n<li>Proposals get revised or dropped</li>\n</ul>\n<p><strong>Round 3: Final Consensus (if needed)</strong></p>\n<ul>\n<li>Resolve remaining disagreements</li>\n<li>Document any contested proposals</li>\n<li>Agree to disagree if necessary</li>\n</ul>\n<h3>The Output</h3>\n<p>After the debate concludes, I get a structured report:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Recommended Additions</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### High Priority</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Critical additions with line counts]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Medium Priority</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Helpful clarifications with line counts]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Recommended Removals 🗑️</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### High-Impact Deletions</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Existing bloat to remove with rationale]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Recommended Alternatives</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Commands to Create</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Workflows that should be slash commands, not docs]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Rejected After Debate</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Proposals discussed but deemed unnecessary]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Net Impact</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Lines added: +15</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Lines removed: -23</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Net change: -8 lines**</span></span></code></pre>\n<p>Notice that last section: <strong>net-negative line changes</strong>. That's the goal. Better focus through subtraction.</p>\n<h2>Why Debate > Single Agent</h2>\n<p>You might be thinking: \"Couldn't you just prompt a single agent to be more critical?\"</p>\n<p>I tried that. It doesn't work as well. Here's why:</p>\n<p><strong>1. Role Conflict</strong>\nWhen a single agent is asked to both propose improvements <em>and</em> critique them, the critique is weak. The agent has already committed to the proposal and suffers from the same confirmation bias humans do.</p>\n<p><strong>2. Surface-Level Pushback</strong>\nA single \"be critical\" prompt produces generic objections: \"This might be too specific\" or \"Consider if this is needed.\" It's not genuine adversarial review.</p>\n<p><strong>3. No Iterative Refinement</strong>\nWith two agents, the Improver actually responds to criticism and revises proposals. A single agent just generates a final output without that back-and-forth refinement.</p>\n<p><strong>4. Emergent Quality</strong>\nThe debate process surfaces insights neither agent would generate alone. The Critic might identify a pattern (\"three of these proposals could become one slash command\"), which then changes the Improver's approach in the next round.</p>\n<p>It's the difference between proofreading your own writing and having someone else review it. The external perspective catches things you can't see.</p>\n<h2>The Technical Implementation</h2>\n<p>This is built using Claude Code's custom slash commands and sub-agent system. Here's the key piece: Claude Code lets you spawn specialized sub-agents from within a conversation, give them specific instructions, and then bring their responses back into the main conversation.</p>\n<p>Here's the high-level structure of what I built:</p>\n<p><strong>File Structure:</strong></p>\n<pre><code>~/.claude/commands/improve-claude-md.md     # Orchestrator prompt\n~/.claude/agents/claude-md-improver/       # Improver agent config\n~/.claude/agents/claude-md-critic/         # Critic agent config\n</code></pre>\n<p><strong>The Orchestrator Command</strong> (<code>~/.claude/commands/improve-claude-md.md</code>):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">You're analyzing our conversation to identify CLAUDE.md improvements.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Process:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">1.</span><span style=\"color:#E1E4E8\"> Read the current CLAUDE.md file (note line count)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">2.</span><span style=\"color:#E1E4E8\"> Review recent conversation (last 20-30 messages)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">3.</span><span style=\"color:#E1E4E8\"> Spawn the Improver agent with context</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">4.</span><span style=\"color:#E1E4E8\"> Spawn the Critic agent with the Improver's proposals</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">5.</span><span style=\"color:#E1E4E8\"> Manage 2-3 debate rounds until convergence</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">6.</span><span style=\"color:#E1E4E8\"> Synthesize final recommendations</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">7.</span><span style=\"color:#E1E4E8\"> Present to user (never auto-apply)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Always track line counts and net impact.</span></span></code></pre>\n<p>The sub-agent configs are much simpler—they just define their focus area. Here's what the Critic looks like:</p>\n<p><strong>The Critic Agent</strong> (<code>~/.claude/agents/claude-md-critic/AGENT.md</code>):\nThe Critic is the more complex of the two sub-agents—it's a 244-line evaluation framework that assesses every proposal along six dimensions (necessity, clarity, over-specification risk, unintended consequences, maintainability, conciseness). It demands message citations, validates the 4-question test, and actively pushes for deletions over additions.</p>\n<p>The Improver proposes additions AND deletions. The Orchestrator manages the whole debate. Each one owns a different dimension.</p>\n<p><strong>The Orchestrator Workflow:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#FFAB70\">1.</span><span style=\"color:#E1E4E8\"> Read current CLAUDE.md (note line count)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">2.</span><span style=\"color:#E1E4E8\"> Review recent conversation (last 20-30 messages)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> I trigger this with </span><span style=\"color:#79B8FF\">`/improve-claude-md`</span><span style=\"color:#E1E4E8\"> after finishing work</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> Claude Code passes the conversation context automatically</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">3.</span><span style=\"color:#E1E4E8\"> Check project files for context</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">4.</span><span style=\"color:#E1E4E8\"> Spawn Improver agent with context</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">5.</span><span style=\"color:#E1E4E8\"> Spawn Critic agent with Improver's proposals</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">6.</span><span style=\"color:#E1E4E8\"> Manage 2-3 debate rounds</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">7.</span><span style=\"color:#E1E4E8\"> Synthesize final recommendations</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">8.</span><span style=\"color:#E1E4E8\"> Present to user (never auto-apply)</span></span></code></pre>\n<p>In practice, this means:</p>\n<ul>\n<li>The Orchestrator reads CLAUDE.md and the conversation</li>\n<li>Spawns Improver to propose changes</li>\n<li>Spawns Critic to challenge those changes</li>\n<li>Manages 2-3 rounds of debate until they converge</li>\n<li>Presents me with final recommendations (never auto-applies)</li>\n</ul>\n<p>The key insight: the debate itself is where the quality comes from. Improver and Critic refine each other's thinking in ways neither could achieve alone.</p>\n<p><strong>Key Design Decisions:</strong></p>\n<ul>\n<li><strong>Never auto-apply changes</strong>: The system only recommends. I approve what goes in.</li>\n<li><strong>Time-boxed debate</strong>: Max 3 rounds prevents endless argument</li>\n<li><strong>Convergence failure protocol</strong>: If agents can't agree after 3 rounds, both perspectives are presented to me</li>\n<li><strong>Metrics throughout</strong>: Line counts, character density, net impact—keeps everyone accountable</li>\n</ul>\n<h2>What I Learned Building This</h2>\n<p><strong>1. Automation Isn't Always About Speed</strong>\nThis system is slower than just asking Claude to suggest updates. But it produces better results. Sometimes the point of automation is quality control, not throughput.</p>\n<p><strong>2. Adversarial Processes Are Underrated</strong>\nWe use them in code review, security testing, and debugging. Why not in AI workflows? Having one agent challenge another creates better outcomes than \"helpful assistant\" mode.</p>\n<p><strong>3. Meta-Problems Are Real Problems</strong>\n\"Managing AI instructions\" sounds silly until your instruction file is 800 lines of contradictory context. Meta-work (work about work) deserves real engineering solutions.</p>\n<p><strong>4. The Irony Is Not Lost on Me</strong>\nI built this entire system in a previous conversation with Claude… and then accidentally closed the terminal before documenting it. The very problem this system solves (capturing important decisions before they're lost) is what happened to the original implementation conversation.</p>\n<p>The lesson? Ship your documentation system before you need it.</p>\n<h2>Key Takeaways</h2>\n<p><strong>What Worked</strong></p>\n<p>The adversarial debate catches stuff single-agent systems never would. Improver proposed adding a section about Velite's RSS generation, but Critic challenged it: \"The Velite config is already in the repo.\" Turns out Improver was right—Claude kept asking about it despite the config being available—but Critic forced a one-line version instead of a paragraph.</p>\n<p>The net-negative focus is real. Most sessions end with more deletions than additions. My CLAUDE.md is actually getting shorter and more focused over time.</p>\n<p><strong>What Changed</strong></p>\n<p>My workflow is slower now, but the results are better. I used to just ask Claude \"any suggestions for CLAUDE.md?\" and get 5-10 additions that I'd half-heartedly implement. Now I run <code>/improve-claude-md</code>, watch the debate play out, and get 2-3 carefully-vetted recommendations with clear rationale.</p>\n<p>The key difference: the debate forces evidence. Improver can't just say \"this might be helpful\"—it has to cite specific message numbers where Claude struggled. Critic can't just say \"this seems unnecessary\"—it has to explain why the evidence is weak or the instruction is redundant.</p>\n<h2>What's Next?</h2>\n<p>I'm considering extending this pattern to other workflows:</p>\n<ul>\n<li>Code review debates (one agent finds issues, another challenges severity)</li>\n<li>Architecture decision records (proposal vs. devil's advocate)</li>\n<li>Documentation quality (writer vs. reader perspective)</li>\n</ul>\n<p>The core insight—that AI agents benefit from structured disagreement just like humans do—feels broadly applicable.</p>\n<hr>\n<p><strong>Have you built multi-agent systems or workflow automation?</strong> I'd love to hear what patterns you've discovered.</p>",
            "url": "https://lukemanning.ie/blog/ai-debates-itself-to-review-my-ai-instructions",
            "title": "I Built an AI to Debate Itself So My AI Instructions Don't Bloat",
            "summary": "<p>I have a confession: I built an AI system to not only review my AI instructions, but to <em>argue with each other</em> about what should and shouldn't be in there.</p>\n<p>I'd been using Claude Code with the sub-agent orchestration feature for a few months when I realized something. (These days I've moved most of this workflow to OpenCode — see <a href=\"/blog/opencode-nested-slash-commands-architecture\">how I consolidated my slash commands</a> for the architectural details.)</p>\n<p>Let me explain how I got here, because the journey from \"I should update this file more often\" to \"let's orchestrate a formal debate between specialized sub-agents\" is… well, it's a journey.</p>\n<h2>The Problem: Context Drift</h2>\n<p>If you're using Claude Code (or any AI coding assistant), you've probably discovered <code>CLAUDE.md</code>, that special instructions file where you tell Claude about your project structure, conventions, and domain knowledge.</p>\n<p>It's incredibly powerful when it's accurate, but it as recent studies have shown it can actually become a hindrance when it contains outdated or stale information. Theo did a great YouTube video covering this concept here:\n<a href=\"https://www.youtube.com/watch?v=GcNu6wrLTJc\">Delete your CLAUDE.md (and your AGENT.md too)</a></p>\n<p>Here's what would typically happen:</p>\n<ol>\n<li>Start a new feature and make a significant update to my project</li>\n<li>Think \"I should add this to CLAUDE.md so Claude knows this\"</li>\n<li>Get distracted by actual work</li>\n<li>Forget to update it</li>\n<li>Repeat</li>\n</ol>\n<p>My CLAUDE.md was missing critical context, leading to repetitive conversations where I'd explain the same project structure or conventions over and over. It also often had stale context, because either the directory structure had changed a bit, or some relevant files had moved.</p>\n<h2>The Naive Solution: Just Ask Claude</h2>\n<p>My first thought was simple: \"Hey Claude, after we finish this conversation, can you suggest updates to my CLAUDE.md file?\"</p>\n<p>Guess what happened?</p>\n<p><strong>Bloat. Massive, uncontrolled bloat.</strong></p>\n<p>Every conversation ended with Claude suggesting 5-10 new sections to add. Here's an example from one session:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">Claude suggested adding:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"When working with components in src/components/ui, always use...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"For API routes in src/app/api, remember to...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"The build process uses Next.js static exports...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"Color palette is defined in tailwind.config.ts...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"Error handling should follow this pattern...\"</span></span></code></pre>\n<p>None of the suggestions included <em>removing</em> anything. Within a few iterations, I would have had a 1,000-line instruction file covering every edge case we'd ever discussed.</p>\n<p>The problem with asking a single AI agent to improve documentation is the same problem humans have: <strong>additive bias</strong>. It's psychologically easier to add information than to delete it. We don't want to lose potentially useful context, so we keep stacking it on.</p>\n<p>But an instruction file that tries to document everything ends up being too long for Claude to effectively use. You hit context limits, instructions contradict each other, and the signal-to-noise ratio tanks.</p>\n<h2>The Insight: I Need a Critic, Not Just a Suggester</h2>\n<p>The breakthrough came when I realized what was missing: <strong>adversarial review</strong>.</p>\n<p>In code reviews, we don't just ask \"what else could we add?\" We ask \"what can we remove?\" and \"is this really necessary?\" That pushback is what keeps codebases maintainable.</p>\n<p>That's when I designed the multi-agent debate system.</p>\n<h2>The Architecture: Orchestrated Debate</h2>\n<p>Here's how it works when I run <code>/improve-claude-md</code>:</p>\n<h3>The Three Roles</h3>\n<p>There are three agents in this system, and I gave each one a specific personality:</p>\n<p><strong>The Orchestrator</strong> runs the show. It reviews our conversation, reads CLAUDE.md, then spawns the other two agents. Its job is to manage the debate and give me a final report.</p>\n<p><strong>The Improver</strong> is the optimist. It looks for patterns where Claude got stuck and proposes fixes. Here's the key: it also proposes deletions, which fights that additive bias I mentioned earlier.</p>\n<p><strong>The Critic</strong> is... well, a critic. It challenges everything. \"Is this <em>really</em> needed?\" it asks. \"Will this still be relevant in two weeks?\" It's the adversarial voice that keeps things lean.</p>\n<h3>The Debate Process</h3>\n<p><strong>Round 1: Initial Proposals</strong></p>\n<ul>\n<li>Improver suggests 3-5 high-priority changes</li>\n<li>Critic challenges each one: \"Is this <em>really</em> necessary?\"</li>\n</ul>\n<p><strong>Round 2: Defense &#x26; Refinement</strong></p>\n<ul>\n<li>Improver responds with evidence from the conversation</li>\n<li>Critic either approves or maintains objections</li>\n<li>Proposals get revised or dropped</li>\n</ul>\n<p><strong>Round 3: Final Consensus (if needed)</strong></p>\n<ul>\n<li>Resolve remaining disagreements</li>\n<li>Document any contested proposals</li>\n<li>Agree to disagree if necessary</li>\n</ul>\n<h3>The Output</h3>\n<p>After the debate concludes, I get a structured report:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Recommended Additions</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### High Priority</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Critical additions with line counts]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Medium Priority</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Helpful clarifications with line counts]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Recommended Removals 🗑️</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### High-Impact Deletions</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Existing bloat to remove with rationale]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Recommended Alternatives</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Commands to Create</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Workflows that should be slash commands, not docs]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Rejected After Debate</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Proposals discussed but deemed unnecessary]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Net Impact</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Lines added: +15</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Lines removed: -23</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8;font-weight:bold\"> **Net change: -8 lines**</span></span></code></pre>\n<p>Notice that last section: <strong>net-negative line changes</strong>. That's the goal. Better focus through subtraction.</p>\n<h2>Why Debate > Single Agent</h2>\n<p>You might be thinking: \"Couldn't you just prompt a single agent to be more critical?\"</p>\n<p>I tried that. It doesn't work as well. Here's why:</p>\n<p><strong>1. Role Conflict</strong>\nWhen a single agent is asked to both propose improvements <em>and</em> critique them, the critique is weak. The agent has already committed to the proposal and suffers from the same confirmation bias humans do.</p>\n<p><strong>2. Surface-Level Pushback</strong>\nA single \"be critical\" prompt produces generic objections: \"This might be too specific\" or \"Consider if this is needed.\" It's not genuine adversarial review.</p>\n<p><strong>3. No Iterative Refinement</strong>\nWith two agents, the Improver actually responds to criticism and revises proposals. A single agent just generates a final output without that back-and-forth refinement.</p>\n<p><strong>4. Emergent Quality</strong>\nThe debate process surfaces insights neither agent would generate alone. The Critic might identify a pattern (\"three of these proposals could become one slash command\"), which then changes the Improver's approach in the next round.</p>\n<p>It's the difference between proofreading your own writing and having someone else review it. The external perspective catches things you can't see.</p>\n<h2>The Technical Implementation</h2>\n<p>This is built using Claude Code's custom slash commands and sub-agent system. Here's the key piece: Claude Code lets you spawn specialized sub-agents from within a conversation, give them specific instructions, and then bring their responses back into the main conversation.</p>\n<p>Here's the high-level structure of what I built:</p>\n<p><strong>File Structure:</strong></p>\n<pre><code>~/.claude/commands/improve-claude-md.md     # Orchestrator prompt\n~/.claude/agents/claude-md-improver/       # Improver agent config\n~/.claude/agents/claude-md-critic/         # Critic agent config\n</code></pre>\n<p><strong>The Orchestrator Command</strong> (<code>~/.claude/commands/improve-claude-md.md</code>):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">You're analyzing our conversation to identify CLAUDE.md improvements.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Process:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">1.</span><span style=\"color:#E1E4E8\"> Read the current CLAUDE.md file (note line count)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">2.</span><span style=\"color:#E1E4E8\"> Review recent conversation (last 20-30 messages)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">3.</span><span style=\"color:#E1E4E8\"> Spawn the Improver agent with context</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">4.</span><span style=\"color:#E1E4E8\"> Spawn the Critic agent with the Improver's proposals</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">5.</span><span style=\"color:#E1E4E8\"> Manage 2-3 debate rounds until convergence</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">6.</span><span style=\"color:#E1E4E8\"> Synthesize final recommendations</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">7.</span><span style=\"color:#E1E4E8\"> Present to user (never auto-apply)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Always track line counts and net impact.</span></span></code></pre>\n<p>The sub-agent configs are much simpler—they just define their focus area. Here's what the Critic looks like:</p>\n<p><strong>The Critic Agent</strong> (<code>~/.claude/agents/claude-md-critic/AGENT.md</code>):\nThe Critic is the more complex of the two sub-agents—it's a 244-line evaluation framework that assesses every proposal along six dimensions (necessity, clarity, over-specification risk, unintended consequences, maintainability, conciseness). It demands message citations, validates the 4-question test, and actively pushes for deletions over additions.</p>\n<p>The Improver proposes additions AND deletions. The Orchestrator manages the whole debate. Each one owns a different dimension.</p>\n<p><strong>The Orchestrator Workflow:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#FFAB70\">1.</span><span style=\"color:#E1E4E8\"> Read current CLAUDE.md (note line count)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">2.</span><span style=\"color:#E1E4E8\"> Review recent conversation (last 20-30 messages)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> I trigger this with </span><span style=\"color:#79B8FF\">`/improve-claude-md`</span><span style=\"color:#E1E4E8\"> after finishing work</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">   -</span><span style=\"color:#E1E4E8\"> Claude Code passes the conversation context automatically</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">3.</span><span style=\"color:#E1E4E8\"> Check project files for context</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">4.</span><span style=\"color:#E1E4E8\"> Spawn Improver agent with context</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">5.</span><span style=\"color:#E1E4E8\"> Spawn Critic agent with Improver's proposals</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">6.</span><span style=\"color:#E1E4E8\"> Manage 2-3 debate rounds</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">7.</span><span style=\"color:#E1E4E8\"> Synthesize final recommendations</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">8.</span><span style=\"color:#E1E4E8\"> Present to user (never auto-apply)</span></span></code></pre>\n<p>In practice, this means:</p>\n<ul>\n<li>The Orchestrator reads CLAUDE.md and the conversation</li>\n<li>Spawns Improver to propose changes</li>\n<li>Spawns Critic to challenge those changes</li>\n<li>Manages 2-3 rounds of debate until they converge</li>\n<li>Presents me with final recommendations (never auto-applies)</li>\n</ul>\n<p>The key insight: the debate itself is where the quality comes from. Improver and Critic refine each other's thinking in ways neither could achieve alone.</p>\n<p><strong>Key Design Decisions:</strong></p>\n<ul>\n<li><strong>Never auto-apply changes</strong>: The system only recommends. I approve what goes in.</li>\n<li><strong>Time-boxed debate</strong>: Max 3 rounds prevents endless argument</li>\n<li><strong>Convergence failure protocol</strong>: If agents can't agree after 3 rounds, both perspectives are presented to me</li>\n<li><strong>Metrics throughout</strong>: Line counts, character density, net impact—keeps everyone accountable</li>\n</ul>\n<h2>What I Learned Building This</h2>\n<p><strong>1. Automation Isn't Always About Speed</strong>\nThis system is slower than just asking Claude to suggest updates. But it produces better results. Sometimes the point of automation is quality control, not throughput.</p>\n<p><strong>2. Adversarial Processes Are Underrated</strong>\nWe use them in code review, security testing, and debugging. Why not in AI workflows? Having one agent challenge another creates better outcomes than \"helpful assistant\" mode.</p>\n<p><strong>3. Meta-Problems Are Real Problems</strong>\n\"Managing AI instructions\" sounds silly until your instruction file is 800 lines of contradictory context. Meta-work (work about work) deserves real engineering solutions.</p>\n<p><strong>4. The Irony Is Not Lost on Me</strong>\nI built this entire system in a previous conversation with Claude… and then accidentally closed the terminal before documenting it. The very problem this system solves (capturing important decisions before they're lost) is what happened to the original implementation conversation.</p>\n<p>The lesson? Ship your documentation system before you need it.</p>\n<h2>Key Takeaways</h2>\n<p><strong>What Worked</strong></p>\n<p>The adversarial debate catches stuff single-agent systems never would. Improver proposed adding a section about Velite's RSS generation, but Critic challenged it: \"The Velite config is already in the repo.\" Turns out Improver was right—Claude kept asking about it despite the config being available—but Critic forced a one-line version instead of a paragraph.</p>\n<p>The net-negative focus is real. Most sessions end with more deletions than additions. My CLAUDE.md is actually getting shorter and more focused over time.</p>\n<p><strong>What Changed</strong></p>\n<p>My workflow is slower now, but the results are better. I used to just ask Claude \"any suggestions for CLAUDE.md?\" and get 5-10 additions that I'd half-heartedly implement. Now I run <code>/improve-claude-md</code>, watch the debate play out, and get 2-3 carefully-vetted recommendations with clear rationale.</p>\n<p>The key difference: the debate forces evidence. Improver can't just say \"this might be helpful\"—it has to cite specific message numbers where Claude struggled. Critic can't just say \"this seems unnecessary\"—it has to explain why the evidence is weak or the instruction is redundant.</p>\n<h2>What's Next?</h2>\n<p>I'm considering extending this pattern to other workflows:</p>\n<ul>\n<li>Code review debates (one agent finds issues, another challenges severity)</li>\n<li>Architecture decision records (proposal vs. devil's advocate)</li>\n<li>Documentation quality (writer vs. reader perspective)</li>\n</ul>\n<p>The core insight—that AI agents benefit from structured disagreement just like humans do—feels broadly applicable.</p>\n<hr>\n<p><strong>Have you built multi-agent systems or workflow automation?</strong> I'd love to hear what patterns you've discovered.</p>",
            "date_modified": "2025-12-28T00:00:00.000Z",
            "tags": [
                "claude-code"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/building-multi-agent-blog-review-system",
            "content_html": "<p>My blog posts were inconsistent. Some too technical. Some lost my voice. Manual reviews weren't catching enough. I needed multiple reviewers: one checking technical accuracy, one preserving my voice, one thinking like a skeptical reader.</p>\n<p><em>Note: This system was originally built with Claude Code. I've since migrated everything to Opencode, but I'm keeping original references because that's how I actually built it. The concepts transfer over. The file paths are just different now.</em></p>\n<p>So I built a multi-agent review system. Four specialized AI agents that debate each draft until it's ready to publish. Not generic AI-generated slop but actual quality control that catches what I'd miss.</p>\n<h2>TL;DR</h2>\n<p>I built a 4-agent review system that catches what I'd miss:</p>\n<ul>\n<li>Technical Educator transforms conversations → blog drafts AND implements revisions through iterative rounds</li>\n<li>Authenticity Guardian ensures it sounds like me (catches AI patterns, corporate speak, tutorial framing)</li>\n<li>Skeptical Reader catches missing context, skipped steps, AND inauthentic framing (from past-Luke's perspective)</li>\n<li>Structure Editor optimizes flow, readability, and authenticity of openings and natural flow</li>\n</ul>\n<p>The pattern is copyable. Each agent reads same file, applies different criteria, writes feedback to disk. Orchestrator coordinates parallel review, aggregate feedback, revise, and repeat until convergence (2-3 rounds with task_id reuse to maintain context).</p>\n<p>Not magic. Just file I/O and well-designed prompts. Overkill? Yes. Does it catch things I'd miss? Also yes.</p>\n<p><strong>Jump to:</strong></p>\n<ul>\n<li><a href=\"#how-agents-actually-communicate-this-confused-me-too\">How agents actually communicate</a></li>\n<li><a href=\"#the-agent-file-structure\">Complete agent definition example</a></li>\n<li><a href=\"#the-debate-protocol\">The debate protocol</a></li>\n</ul>\n<hr>\n<p>Here's what I built, how it works, and what surprised me along the way.</p>\n<h2>Context: What I Built This With</h2>\n<p>I built this with Claude Code, the CLI from Anthropic where Claude can read/write files, run commands, and maintain context across your project.</p>\n<p>I'd already been using it for a while, so I knew the directory pattern:</p>\n<ul>\n<li><strong>Agents</strong> in <code>.claude/agents/&#x3C;name>/AGENT.md</code> (specialized AI personas with evaluation frameworks)</li>\n<li><strong>Skills</strong> in <code>.claude/skills/</code> (single-purpose tools I invoke with slash commands)</li>\n<li><strong>Commands</strong> in <code>.claude/commands/</code> (orchestrators that coordinate multiple agents)</li>\n</ul>\n<p>Claude Code automatically discovers files in <code>.claude/</code>. A file at <code>.claude/commands/review-blog-post.md</code> becomes the slash command <code>/review-blog-post</code>. Simple pattern, but it took me quite a while to figure out how to chain agents together properly.</p>\n<p>I'd already built two tools before starting this project:</p>\n<ul>\n<li><code>/agent-generator</code>: Creates well-structured agent definitions automatically</li>\n<li><code>/expertise</code>: Synthesizes frameworks from domain experts to ground agents in real methodologies</li>\n</ul>\n<p>These are my custom tools—not built-in Claude Code features. I built them using the same patterns I'm about to show you.</p>\n<h2>The Problem: Quality Control at Scale</h2>\n<p>I have two different ways I create blog posts, and both of them were creating quality issues:</p>\n<p><strong>Writing myself</strong>: I'll jot down ideas over days or weeks, get a messy braindump of thoughts, then ask AI to structure it into something coherent. This works great when I've been thinking about a topic for a while—but AI would often lose my voice or turn it into a tutorial.</p>\n<p><strong>AI-generated from conversation</strong>: Sometimes I'll have a really good conversation with Claude where I learned something through debugging. Instead of rewriting it from scratch, I'll ask AI to generate a post directly from the conversation history. These were even worse. Too polished, too generic, missing the struggle.</p>\n<p>Both approaches needed serious cleanup.</p>\n<p>Both approaches create messy drafts that need work—and that's where the quality issues creep in:</p>\n<ul>\n<li>Some posts became more like tutorials instead of journey-sharing</li>\n<li>Posts would sound too polished (clearly AI-generated)</li>\n<li>I'd skip \"obvious\" steps that weren't obvious to past-me</li>\n<li>Structure would be all over the place</li>\n<li>My authentic voice would get lost in editing &#x26; review</li>\n</ul>\n<p>I needed a system that could:</p>\n<ol>\n<li>Transform my raw notes/conversations into blog drafts</li>\n<li>Catch quality issues before publishing</li>\n<li>Preserve my authentic voice</li>\n<li>Ensure completeness (no missing steps or context)</li>\n</ol>\n<p>The solution I decided to explore wsa letting specialized AI agents debate each other until they converge on something worth publishing.</p>\n<h2>The Multi-Agent Architecture</h2>\n<p>I ended up with four specialized agents, each with a specific job:</p>\n<h3>1. Technical Educator (The Creator &#x26; Reviser)</h3>\n<p><strong>Job</strong>: Transform raw conversations or notes into blog post drafts AND implement revisions based on critic feedback through iterative rounds.</p>\n<p><strong>Based on</strong>: Real methodologies from swyx (\"learn in public\"), Julia Evans (debugging narratives), Josh Comeau (mental models first), Andy Matuschak (progressive disclosure), and Anne-Laure Le Cunff (ship version 1.0).</p>\n<p><strong>File location</strong>: <code>.claude/agents/blog-technical-educator/AGENT.md</code></p>\n<p>This agent has TWO phases:</p>\n<p><strong>Phase 1 - Create Drafts</strong>:\nTakes my messy notes or conversation transcripts and structures them into:</p>\n<ul>\n<li>Opening hook (the specific problem)</li>\n<li>Story arc (my debugging journey)</li>\n<li>Mental model (how it actually works)</li>\n<li>Practical solution (what to do)</li>\n<li>Key takeaways</li>\n</ul>\n<p><strong>Phase 2 - Implement Revisions</strong>:\nAfter receiving critic feedback (overlapping concerns, conflicting input), the agent:</p>\n<ul>\n<li>Prioritizes issues (high/medium/low priority)</li>\n<li>Implements targeted revisions (not complete rewrites)</li>\n<li>Provides complete revised posts (not just suggestions)</li>\n<li>Iterates with critics for 2-3 rounds until convergence</li>\n<li>Uses stored task_ids to maintain conversation context across rounds</li>\n</ul>\n<p>The framework is grounded in actual expert approaches. Not generic \"write a blog post\" instructions.</p>\n<h3>2. Authenticity Guardian (Voice Critic)</h3>\n<p><strong>Job</strong>: Ensure posts sound like me, not generic AI content.</p>\n<p><strong>File location</strong>: <code>.claude/agents/blog-authenticity-guardian/AGENT.md</code></p>\n<p><strong>The YAML frontmatter explained</strong>: Each agent file starts with YAML metadata that tells Claude Code what model to use, which tools the agent has access to, and basic identification. The markdown content below defines the agent's expertise and protocols.</p>\n<p>This agent is ruthless about voice violations:</p>\n<p><strong>Red flags it catches</strong>:</p>\n<ul>\n<li>Corporate speak (\"leveraging,\" \"optimizing,\" \"in today's landscape\")</li>\n<li>Generic transitions that add no value</li>\n<li>Vague generalizations where specifics would fit</li>\n<li>Lecturing tone instead of sharing tone</li>\n<li>Perfect polish without personality</li>\n</ul>\n<p><strong>Example critique</strong>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">🚨 Critical violation - Corporate speak</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Problem: \"In order to optimize performance, it's recommended to</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">leverage memoization techniques.\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Authentic alternative: \"I was getting way too many re-renders.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Turns out, memoization fixed it - React stopped recalculating</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">stuff it had already figured out.\"</span></span></code></pre>\n<p>It has <strong>veto power</strong> on voice authenticity. If it doesn't sound like me, it doesn't ship.</p>\n<p><strong>How veto power works</strong>: In the convergence protocol, if Authenticity Guardian scores a post below 7/10, the Technical Educator must revise before proceeding. The orchestrator enforces this - no publication happens without voice approval.</p>\n<h3>3. Skeptical Reader (Completeness &#x26; Authentic Framing Critic)</h3>\n<p><strong>Job</strong>: Read from a beginner's perspective and find confusion points, missing context, AND inauthentic framing.</p>\n<p><strong>File location</strong>: <code>.claude/agents/blog-skeptical-reader/AGENT.md</code></p>\n<p>This agent represents my target audience: tech support engineers, junior developers, people learning in public (basically past-Luke).</p>\n<p><strong>What it flags</strong>:</p>\n<ul>\n<li>Missing version numbers or environment details</li>\n<li>Skipped steps that seem \"obvious\" to experts</li>\n<li>Logical gaps in the story (\"wait, how did we get here?\")</li>\n<li>Unanswered questions readers would have</li>\n<li>Cognitive overload (too much at once)</li>\n<li><strong>Inauthentic framing</strong>: Prescriptive \"you should\" language, generic tutorial patterns, magic jumps that skip reasoning</li>\n</ul>\n<p><strong>Example critique</strong>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">🚨 Critical Gap - Missing Context</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Problem: \"Just run the build command\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Reader questions:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Which command?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> In what directory?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> What should the output look like?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> How do I know if it worked?</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Fix needed: Show exact command, expected output, success criteria</span></span></code></pre>\n<h3>4. Structure Editor (Flow, Readability, &#x26; Authenticity Critic)</h3>\n<p><strong>Job</strong>: Optimize structure, pacing, visual hierarchy for web reading, AND authenticity of openings/natural flow.</p>\n<p><strong>File location</strong>: <code>.claude/agents/blog-structure-editor/AGENT.md</code></p>\n<p>This agent ensures posts are designed for how people actually read on the web. Scanning, skimming, then diving deep. Also checks that openings sound like Luke (not tutorial hooks) and that the flow feels natural, not forced.</p>\n<p><strong>What it evaluates</strong>:</p>\n<ul>\n<li>Opening authenticity (sounds like Luke or tutorial hook?)</li>\n<li>Visual hierarchy (can you understand post from headings alone?)</li>\n<li>Pacing (mix of short/long paragraphs, visual breaks)</li>\n<li>Flow (smooth transitions, natural progression, not forced)</li>\n<li>Engagement (does each section pull you to the next?)</li>\n</ul>\n<p><strong>Example critique</strong>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">⚠️ Structure Issue - Weak Opening</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Problem: \"Asynchronous JavaScript is an important concept...\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Impact: Vague introduction, no hook, buried lede.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">No reason to keep reading.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Fix: Start with specific error or problem:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">\"I kept hitting </span><span style=\"color:#79B8FF\">`Cannot read property 'map' of undefined`</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">and honestly, I was stumped for hours...\"</span></span></code></pre>\n<h2>The Agent File Structure</h2>\n<p>I want to show you what an actual agent file looks like, not just describe the structure. This is the Authenticity Guardian, which I created because I kept getting AI-generated slop that sounded like documentation instead of me.</p>\n<p>Here's the file:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">description</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Ensures blog posts sound like Luke, not generic AI content</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">model</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">claude-sonnet-4</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\"># Authenticity Guardian</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">You are a specialist in authentic voice detection for Luke Manning's blog. Your job is to ensure posts sound like Luke, conversational, specific, honest about confusion, not like generic AI content or tutorials.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## What You Evaluate</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### 1. Personal vs. Generic</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Specific: \"I spent quite a while debugging...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Vague: \"This was challenging...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Generic AI phrases to flag: \"Let's explore,\" \"In today's landscape\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### 2. Conversational vs. Corporate</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Conversational: \"I was getting way too many re-renders\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Corporate: \"In order to optimize performance, leverage memoization\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### 3. Humble Sharing vs. Expert Lecturing</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Journey framing: \"Here's what I did\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Instructional framing: \"You should do X\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Red Flags</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">If you see these, flag as CRITICAL:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"Let's explore,\" \"Let's dive into\" (AI signature patterns)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"It is recommended,\" \"You should\" (instructional mode)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"Leverage,\" \"Optimize\" without specific examples</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"One of the best practices is...\" (generic advice)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Overuse of transition words: \"Moreover,\" \"Furthermore,\" \"Additionally\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Scoring</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> 9-10: Unmistakably Luke</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> 7-8: Minor voice breaks, needs polish</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Below 7: Major voice violation, needs revision</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Provide specific line-by-line feedback with before/after examples.</span></span></code></pre>\n<p>This structure (evaluation framework, red flags with specific examples, clear scoring) made agents effective. I learned the hard way that vague instructions produce vague feedback.</p>\n<h2>How Agents Actually Communicate (This Confused Me Too)</h2>\n<p>Here's what I got wrong at first: I assumed agents would talk to each other directly, passing messages back and forth like a Slack channel.</p>\n<p>Nope.</p>\n<p>Agents don't communicate. They don't even know other agents exist. Each one is a completely isolated Claude session reading the same file.</p>\n<p><strong>Here's how it actually works:</strong></p>\n<ol>\n<li><strong>Orchestrator (me or a slash command) triggers the workflow</strong></li>\n<li><strong>Technical Educator reads the source</strong> (conversation transcript or existing post)</li>\n<li><strong>File gets written</strong> to disk (e.g., <code>draft-post.md</code>)</li>\n<li><strong>Three critic agents launch in parallel</strong> (separate Claude sessions via the Task tool)\n<ul>\n<li>Each reads the SAME file from disk</li>\n<li>Each applies its own review criteria</li>\n<li>Each returns structured feedback</li>\n<li><strong>IMPORTANT</strong>: Orchestrator captures <code>task_id</code> from each critic for reuse in subsequent rounds</li>\n</ul>\n</li>\n<li><strong>Orchestrator aggregates results</strong> (combines the three reviews)</li>\n<li><strong>Technical Educator gets compiled feedback</strong> (as a single prompt) + stored task_ids</li>\n<li><strong>Technical Educator revises</strong> based on feedback, provides complete revised post</li>\n<li><strong>Critics re-review using stored task_ids</strong> (maintains conversation context across rounds)</li>\n<li><strong>Repeat steps 4-8</strong> until all critics approve (or max 3 rounds hit)</li>\n</ol>\n<p>The \"communication\" is just file I/O and prompt engineering. Each agent writes its opinion, the orchestrator reads those opinions, compiles them into context for the next agent. The task_id reuse ensures critics remember their previous feedback and maintain conversation context across iterative rounds.</p>\n<p><strong>Why this matters:</strong> You don't need any fancy agent framework or message bus. Just:</p>\n<ul>\n<li>Use the Task tool to spawn agents with specific prompts</li>\n<li>Pass file paths as context</li>\n<li>Aggregate outputs in the orchestrator</li>\n<li>Feed compiled results to the next agent</li>\n</ul>\n<p><strong>In Claude Code CLI, you invoke commands like:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">/review-blog-post-multi-agent</span><span style=\"color:#9ECBFF\"> @content/posts/my-post.md</span></span></code></pre>\n<p>The orchestrator file then uses the Task tool to spawn parallel agents:</p>\n<pre><code>task(blog-skeptical-reader, \"Review this draft for completeness gaps\")\ntask(blog-authenticity-guardian, \"Check this for voice authenticity\")\ntask(blog-structure-editor, \"Evaluate structure and flow\")\n</code></pre>\n<p>That's it. That's the whole multi-agent system.</p>\n<p>The complexity isn't in the infrastructure. It's in the prompt design. Each agent needs clear evaluation frameworks, specific examples, and structured output formats. Get those right, and the orchestration is straightforward.</p>\n<h2>The Two Workflows</h2>\n<p>I built two separate orchestration workflows:</p>\n<h3>Workflow 1: Generate Blog Post from Conversation</h3>\n<p><strong>Command</strong>: Slash command (invokes skill)\n<strong>File</strong>: <code>.claude/commands/generate-blog-post-multi-agent.md</code></p>\n<p><strong>How it works</strong>:</p>\n<ol>\n<li><strong>Orchestrator analyzes</strong> last 20-40 messages in conversation</li>\n<li><strong>Extracts</strong> the story arc (problem → attempts → breakthrough → solution)</li>\n<li><strong>Identifies</strong> technical artifacts (error messages, code, version numbers)</li>\n<li><strong>Spawns Technical Educator</strong> (Phase 1) to create initial draft</li>\n<li><strong>Spawns all 3 critics in parallel</strong> to review (captures task_ids for reuse)</li>\n<li><strong>Aggregates feedback</strong> (critical issues, overlapping concerns, conflicts)</li>\n<li><strong>Technical Educator revises</strong> (Phase 2) based on feedback + provides complete revised post</li>\n<li><strong>Critics re-review using stored task_ids</strong> (2-3 rounds until convergence)</li>\n<li><strong>Delivers</strong> publication-ready markdown</li>\n</ol>\n<p><strong>What makes this work</strong>: The orchestrator maintains the core principles (learn in public, authenticity over polish, write for past-self), ensures agents stay grounded in those values, and uses task_id reuse to maintain conversation context across iterative rounds.</p>\n<h3>How Agents Actually \"Run\": What Happens Under the Hood</h3>\n<p>I spent quite a while thinking agents would talk to each other directly, passing messages like a Slack channel. Nope.</p>\n<p>Agents don't communicate. They don't even know other agents exist. Each one is a completely isolated Claude session reading the same file.</p>\n<p>Here's what actually happens when the orchestrator triggers a review:</p>\n<p><strong>The orchestrator prepares context:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">You are the Skeptical Reader from </span><span style=\"color:#79B8FF\">`.claude/agents/blog-skeptical-reader/AGENT.md`</span><span style=\"color:#E1E4E8\">.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Your task: Review this draft blog post for completeness gaps.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Draft content here]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Provide your review in the standard format: score, critical issues,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">medium priority, low priority, what's working well.</span></span></code></pre>\n<p><strong>Claude loads the agent definition:</strong>\nWhen the orchestrator specifies <code>task(blog-skeptical-reader, ...)</code>, Claude Code automatically loads the AGENT.md file. This file has the evaluation framework, examples of good/bad content, and output format. The orchestrator doesn't need to know what's inside—Claude Code handles it.</p>\n<p><strong>Agent responds with structured feedback:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Skeptical Reader Review</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Overall Score**</span><span style=\"color:#E1E4E8\">: 7/10</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Critical Issues (Must Fix)**</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Missing Next.js version number (line 45)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"Simply do X\" assumes reader knowledge (line 89)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Medium Priority (Should Fix)**</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Vague heading \"Implementation\" → suggest \"The Fix: useState with Null Check\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**What's Working Well**</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Opening hook is specific and relatable</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Code examples include error messages</span></span></code></pre>\n<p><strong>Here's the trick—each agent runs in parallel:</strong></p>\n<p>The orchestrator spawns all three critics at the exact same time. They don't know about each other, they can't see each other's feedback, and they all return results independently. This is what prevents bias contamination.</p>\n<p><strong>Orchestrator aggregates all three reviews:</strong>\nThe orchestrator waits for all three parallel agent reviews, then creates a unified feedback document.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Aggregated Feedback for Technical Educator</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Critical Issues (All 3 agents flagged):</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Missing version numbers (Skeptical Reader)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Corporate speak in paragraph 3 (Authenticity Guardian)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Wall of text in \"Implementation\" section (Structure Editor)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Overlapping Concerns:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Both Authenticity Guardian and Structure Editor want shorter paragraphs</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Both Skeptical Reader and Structure Editor want better headings</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Approval Status:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Authenticity Guardian: 6/10 (needs revision)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Skeptical Reader: 7/10 (needs revision)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Structure Editor: 8/10 (approve after fixes)</span></span></code></pre>\n<p><strong>Technical Educator revises:</strong>\nThe orchestrator spawns Technical Educator again with the aggregated feedback. It implements fixes and documents what changed.</p>\n<p><strong>Critics re-review using stored task_ids:</strong>\nThis is the part that confused me at first. How do critics remember their previous feedback? The answer is task_id reuse. When the orchestrator first spawns each critic, it captures a <code>task_id</code> from the response. When resubmitting the revised draft, it reuses that same <code>task_id</code>. This maintains conversation context across rounds so critics remember what they flagged before.</p>\n<p>The whole multi-agent system is just file I/O and task_id reuse. Agents write opinions to disk, orchestrator reads and compiles them, feeds results to the next agent. No fancy message bus needed.</p>\n<p>This is how the orchestrator manages the entire multi-agent workflow, spawning agents sequentially or in parallel, aggregating their outputs, and enforcing convergence criteria.</p>\n<h3>Workflow 2: Review Existing Blog Post</h3>\n<p><strong>Command</strong>: <code>/review-blog-post-multi-agent @content/posts/post-slug.md</code>\n<strong>File</strong>: <code>.claude/commands/review-blog-post-multi-agent.md</code></p>\n<p><strong>How it works</strong>:</p>\n<ol>\n<li><strong>Read existing post</strong> (analyze structure, voice, completeness)</li>\n<li><strong>Review voice baseline</strong> (check 2-3 recent posts for consistency)</li>\n<li><strong>Spawn all 3 critics in parallel</strong> (captures task_ids for reuse)</li>\n<li><strong>Aggregate feedback</strong> (critical/medium/low priority)</li>\n<li><strong>Spawn Technical Educator</strong> with feedback + stored task_ids</li>\n<li><strong>Technical Educator provides complete revised post</strong> + revision summary</li>\n<li><strong>Critics re-review using stored task_ids</strong> (approve/reject/refine)</li>\n<li><strong>Converge after 2-3 rounds</strong></li>\n<li><strong>Deliver actionable recommendations</strong> with before/after text</li>\n</ol>\n<p><strong>Output includes</strong>:</p>\n<ul>\n<li>Quick wins (5-10 minute fixes)</li>\n<li>Moderate improvements (30-60 minute rewrites)</li>\n<li>Major rewrites (only if fundamental issues)</li>\n<li>What to preserve (don't change these sections)</li>\n<li>Contested items (user decides)</li>\n</ul>\n<h2>The Debate Protocol</h2>\n<p>Here's what makes this system actually work: <strong>adversarial debate with convergence</strong>.</p>\n<h3>Round 1: Initial Review</h3>\n<p>All three critics review in parallel. Each provides:</p>\n<ul>\n<li>Overall score (X/10)</li>\n<li>Critical issues (must fix)</li>\n<li>Medium priority (should fix)</li>\n<li>Low priority (nice to have)</li>\n<li>What's working well</li>\n</ul>\n<p><strong>How critics calculate scores</strong>: Each agent evaluates its 6 dimensions and assigns a score based on:</p>\n<ul>\n<li><strong>Authenticity Guardian</strong>: 9-10 = unmistakably Luke, 7-8 = minor voice breaks, below 7 = needs revision</li>\n<li><strong>Skeptical Reader</strong>: 9-10 = no gaps or confusion, 7-8 = 1-2 missing details, below 7 = critical gaps</li>\n<li><strong>Structure Editor</strong>: 9-10 = perfect flow and hierarchy, 7-8 = minor pacing issues, below 7 = structural problems</li>\n</ul>\n<p>The score isn't arbitrary - it's calculated from the count and severity of issues found across all dimensions.</p>\n<h3>Round 2: Revision</h3>\n<p>Technical Educator receives aggregated feedback + stored task_ids and:</p>\n<ul>\n<li>Addresses all critical issues</li>\n<li>Tackles medium priority items</li>\n<li>Makes judgment calls on conflicts</li>\n<li>Documents what was changed and why</li>\n<li><strong>Provides complete revised post</strong> (full markdown, not just suggestions)</li>\n</ul>\n<p>Critics re-review using stored task_ids (maintains conversation context) and either approve or escalate remaining concerns.</p>\n<h3>Round 3: Final Refinement (if needed)</h3>\n<p>For contested items or remaining gaps. By round 3, most things have converged.</p>\n<h3>Convergence Criteria</h3>\n<p>A post is ready when:</p>\n<ul>\n<li>All three critics approve</li>\n<li>Story arc is clear</li>\n<li>Mental models explained before implementation</li>\n<li>Content is specific and searchable</li>\n<li>Voice is authentic</li>\n<li>No technical inaccuracies</li>\n</ul>\n<p><strong>Convergence failure protocol</strong>: If agents can't agree after 3 rounds, document both perspectives and let me decide.</p>\n<p>Example contested issue:</p>\n<ul>\n<li>Authenticity wants rambling paragraph (authentic voice)</li>\n<li>Structure wants visual breaks (better readability)</li>\n<li><strong>Resolution</strong>: Keep the words, add paragraph breaks</li>\n</ul>\n<h3>Real Example: Reviewing This Very Post</h3>\n<p>Want to see this in action? Here's what happened when I ran this post through the system:</p>\n<p><strong>Round 1 Feedback:</strong></p>\n<p>Authenticity Guardian (6.5/10): \"Too much formal hedge-language. You used 'It's worth noting' 11 times. That's not Luke—that's documentation voice.\"</p>\n<p>Skeptical Reader (7/10): \"Where's the complete AGENT.md file? You mention agents but never show one. How do agents communicate—file I/O, API calls, what?\"</p>\n<p>Structure Editor (7/10): \"Hook buried 300 words deep. Dense 400-word paragraphs. No TL;DR for a 3,600-word post.\"</p>\n<p><strong>My revisions:</strong></p>\n<ul>\n<li>Killed all \"It's worth noting\" instances → replaced with direct statements</li>\n<li>Added complete 60-line Authenticity Guardian definition</li>\n<li>Added \"How Agents Actually Communicate\" section explaining file I/O</li>\n<li>Added TL;DR with jump links</li>\n<li>Strengthened opening hook</li>\n</ul>\n<p><strong>Round 2 Feedback:</strong></p>\n<p>Authenticity Guardian (7.5/10): Better, but needs more struggle journey\nSkeptical Reader (9/10): APPROVE—all critical gaps fixed\nStructure Editor (8/10): Major improvements, minor pacing tweaks needed</p>\n<p>That's the system working. Multiple perspectives, specific feedback, iterative improvement.</p>\n<h2>The Journey of Building This</h2>\n<h3>Starting Point: The <code>/agent-generator</code> Skill</h3>\n<p>I didn't write these agent definitions from scratch. I used a meta-skill I'd built previously: <code>/agent-generator</code>.</p>\n<p>This skill creates well-structured agent definitions by:</p>\n<ol>\n<li>Understanding the agent's role</li>\n<li>Invoking <code>/expertise</code> skill to ground in real methodologies</li>\n<li>Designing interaction protocols</li>\n<li>Defining output formats</li>\n</ol>\n<h3>The Orchestrator Pattern</h3>\n<p>The orchestrators (in <code>.claude/commands/</code>) don't contain agent logic. They:</p>\n<ul>\n<li>Prepare context</li>\n<li>Spawn agents in sequence</li>\n<li>Aggregate feedback</li>\n<li>Manage convergence</li>\n<li>Format final output</li>\n</ul>\n<p><strong>Key decision</strong>: Parallel review with sequential revision.</p>\n<p>Critics review simultaneously (faster), but revisions happen sequentially (prevents chaos).</p>\n<h3>The Expert Grounding Approach</h3>\n<p>Each agent is grounded in real methodologies:</p>\n<p><strong>Technical Educator</strong>:</p>\n<ul>\n<li>swyx: \"Learn in public\" philosophy, document the journey</li>\n<li>Julia Evans: Debugging narratives, specific error messages</li>\n<li>Josh Comeau: Mental models before implementation</li>\n<li>Andy Matuschak: Progressive disclosure (simple → complex)</li>\n<li>Anne-Laure Le Cunff: Ship version 1.0, iterate</li>\n</ul>\n<p><strong>Authenticity Guardian</strong>:</p>\n<ul>\n<li>Content strategy voice analysis</li>\n<li>AI detection patterns</li>\n<li>Brand alignment frameworks</li>\n</ul>\n<p><strong>Skeptical Reader</strong>:</p>\n<ul>\n<li>Cognitive load theory</li>\n<li>Curse of knowledge awareness</li>\n<li>Technical documentation best practices</li>\n</ul>\n<p><strong>Structure Editor</strong>:</p>\n<ul>\n<li>Inverted pyramid (journalism)</li>\n<li>Web reading behavior (F-pattern scanning)</li>\n<li>Readability frameworks (Flesch-Kincaid)</li>\n</ul>\n<p>This grounding prevents generic \"AI helping AI\" nonsense. Each agent has real frameworks to reference.</p>\n<h2>What Didn't Work (And Why)</h2>\n<h3>Attempt 1: Single Agent Doing All Three Jobs</h3>\n<p>I started optimistically, one agent to rule them all. Check voice, completeness, and structure all in one pass. Seemed efficient.</p>\n<p><strong>What actually happened</strong>:\nThe agent would catch voice issues but miss missing code examples. Or notice structural problems but completely gloss over authenticity breaks. It was like asking one person to be a copy editor, a fact-checker, and a voice coach simultaneously. Something always got missed.</p>\n<p><strong>Why it failed</strong>:\nToo many competing objectives. The agent couldn't specialize. Trying to hold three different evaluation frameworks at once meant it couldn't apply any of them well. I kept tweaking the prompt, thinking the issue was in how I phrased things. Spent quite a while before realizing the real problem was the architecture itself.</p>\n<h3>Attempt 2: Sequential Review (Voice → Skeptical → Structure)</h3>\n<p>Okay, split them up. Run Voice first, then Skeptical, then Structure. Each agent sees the previous agent's feedback and builds on it.</p>\n<p><strong>What actually happened</strong>:\nThe Structure Editor would see \"Voice score: 6/10\" and unconsciously lower its own standards. Or Skeptical Reader would notice Authenticity Guardian flagged something as critical, then ignore a similar issue because \"that's already being addressed.\"</p>\n<p><strong>Why it failed</strong>:\nTwo problems:</p>\n<p>First: Bias contamination. Agents were influenced by each other's scores instead of evaluating independently.</p>\n<p>Second: Terrible performance. Three sequential Claude calls meant 30+ seconds of waiting for each review. I'd run a post through the system, go grab coffee, come back, and still be waiting on the third agent. Not sustainable.</p>\n<p>I thought sequential would be better, each agent could learn from the previous one. Instead, it just created echo chambers where agents converged on \"good enough\" instead of pushing for better.</p>\n<h2>What Worked: The Breakthrough</h2>\n<h3>Attempt 3: Parallel Review (Current System)</h3>\n<p>Launch all three critics at once, each reviewing independently. Aggregate results afterward.</p>\n<p><strong>Why this worked</strong>:</p>\n<ul>\n<li>No bias contamination—agents can't see each other's feedback</li>\n<li>Faster execution (parallel API calls)</li>\n<li>Agents can disagree, which surfaces interesting edge cases</li>\n<li>Technical Educator gets unfiltered input from all perspectives</li>\n</ul>\n<p>The breakthrough was realizing that disagreement is valuable. When Authenticity Guardian wants rambling paragraphs (authentic voice) and Structure Editor wants visual breaks (readability), that tension forces the Technical Educator to find creative solutions like keeping the words but adding paragraph breaks.</p>\n<h2>What Surprised Me</h2>\n<h3>Convergence Happens Faster Than Expected</h3>\n<p>Most posts converge in 2 rounds:</p>\n<ul>\n<li>Round 1: 5-10 issues flagged</li>\n<li>Round 2: All addressed, critics review again</li>\n<li>Round 3: Final adjustments and polish</li>\n</ul>\n<p>Going past round 3 is rare (only for major rewrites or contested items).</p>\n<h3>The System Catches Things I'd Miss</h3>\n<p><strong>Example from recent review</strong>:</p>\n<ul>\n<li>Missing Next.js version number</li>\n<li>\"Simply do X\" (curse of knowledge)</li>\n<li>Vague heading \"Implementation\" → Changed to \"The Fix: useState with Null Check\"</li>\n<li>Wall of text (350 words, no breaks) → Split into 3 paragraphs with code block</li>\n</ul>\n<p>All things I'd probably ship without noticing.</p>\n<h3>Voice Preservation Actually Works</h3>\n<p>The Authenticity Guardian is brutal but accurate. It catches:</p>\n<ul>\n<li>Corporate buzzwords I'd unconsciously use</li>\n<li>Generic transition phrases (\"Let's explore...\")</li>\n<li>Expert assumptions (\"Obviously you'll need to...\")</li>\n<li>Common AI phrases (\"But honestly?\")</li>\n</ul>\n<p>And it suggests authentic alternatives that sound like me:</p>\n<ul>\n<li>\"I kept hitting this error for two hours...\"</li>\n<li>\"Turns out, the issue was...\"</li>\n<li>\"Here's what surprised me...\"</li>\n</ul>\n<h2>The Results</h2>\n<p>All of this sounds great on paper. Does it actually work?</p>\n<p>Honestly, I wasn't sure at first. The first few runs were slow. I kept tweaking prompts, adjusting scoring thresholds, chasing edge cases where agents would argue forever.</p>\n<p>But after a while, the system settled in. Here's what I've observed:</p>\n<p><strong>Quality improvement</strong>: The agents consistently catch things I'd miss, voice breaks, missing version numbers, and obvious steps that aren't obvious at all</p>\n<p><strong>Consistency</strong>: Every post follows the same quality bar now, which wasn't true when I was reviewing manually</p>\n<p><strong>Learning</strong>: The critic feedback teaches me what to avoid in future writing</p>\n<p>Not perfect data, I haven't been tracking this scientifically. But qualitatively, it's way better than my manual reviews.</p>\n<hr>\n<p>These results didn't come from magic. They came from careful design. Here's what's under the hood: file structure, agent definitions, and orchestration patterns that make this work.</p>\n<h2>Agent Structure (High-Level)</h2>\n<p>Each agent follows the same pattern:</p>\n<p><strong>File</strong>: <code>.opencode/agent/&#x3C;agent-name>.md</code></p>\n<p><strong>Core components</strong>:</p>\n<ul>\n<li>YAML frontmatter (description, tools, permissions)</li>\n<li>Mission statement</li>\n<li>Evaluation framework (specific dimensions to check)</li>\n<li>Interaction protocol (how it works with other agents)</li>\n<li>Output format (structured feedback)</li>\n<li>Examples of good/bad content</li>\n</ul>\n<p><strong>What makes this work</strong>:</p>\n<ul>\n<li>Clear job description (one specialty per agent)</li>\n<li>Grounded in real methodologies (not \"help write blog\")</li>\n<li>Specific patterns to flag (not vague \"check quality\")</li>\n<li>Structured output (orchestrator can parse it)</li>\n</ul>\n<p>The orchestrator coordinates workflow: prepare context, spawn agents, aggregate feedback, manage convergence, format output. Agents contain all evaluation logic, and their specialization is what makes the system work.</p>\n<h3>Six-Dimensional Review Frameworks</h3>\n<p>Each critic evaluates across 6 specific dimensions:</p>\n<p><strong>Authenticity Guardian</strong>:</p>\n<ol>\n<li>Personal vs. Generic</li>\n<li>Humble Sharing vs. Expert Lecturing</li>\n<li>Specific vs. Vague</li>\n<li>Conversational vs. Corporate</li>\n<li>Brand Alignment</li>\n<li>AI Detection Signals</li>\n</ol>\n<p><strong>Skeptical Reader</strong>:</p>\n<ol>\n<li>Completeness</li>\n<li>Context</li>\n<li>Gaps</li>\n<li>Questions</li>\n<li>Cognitive Load</li>\n<li>Curse of Knowledge</li>\n</ol>\n<p><strong>Structure Editor</strong>:</p>\n<ol>\n<li>Opening Hook</li>\n<li>Visual Hierarchy</li>\n<li>Pacing</li>\n<li>Flow</li>\n<li>Engagement</li>\n<li>Web Readability</li>\n</ol>\n<p>Each dimension has clear examples of good/bad and specific things to flag.</p>\n<h2>What's Next</h2>\n<h3>Immediate Improvements</h3>\n<ol>\n<li><strong>Internal linking agent</strong>: Automatically suggest links to related posts</li>\n<li><strong>SEO optimizer</strong>: Ensure titles/descriptions hit character limits</li>\n<li><strong>Code validator</strong>: Run code examples to ensure they actually work</li>\n</ol>\n<h3>Long-term Vision</h3>\n<ol>\n<li><strong>Feedback loop</strong>: Track which posts get \"I got stuck at X\" comments, feed that back to Skeptical Reader</li>\n<li><strong>Style evolution</strong>: Let agents learn from high-performing posts</li>\n<li><strong>Topic suggester</strong>: Analyze conversations to identify blog-worthy moments</li>\n</ol>\n<h3>Meta-Learning</h3>\n<p>This whole process is itself blog-worthy content. I'm using the system to review this post about building the system.</p>\n<p><strong>Inception</strong>: The agents are currently debating this very post you're reading.</p>\n<h2>Key Takeaways</h2>\n<ul>\n<li><strong>Multi-agent systems work when agents have real expertise</strong> - Don't just spawn \"helper agents.\" Ground them in actual frameworks and methodologies.</li>\n<li><strong>Adversarial debate makes better posts</strong>. The friction between Authenticity Guardian and Structure Editor leads to posts that are both genuine and readable.</li>\n<li><strong>Convergence protocols prevent endless iteration</strong>. 2-3 rounds with clear approval criteria. After that, ship or document the trade-off.</li>\n<li><strong>Orchestrators maintain principles</strong>. The orchestrator's job is reminding agents of core values: learn in public, authenticity over polish, write for past-self.</li>\n<li><strong>The system teaches you</strong>. After seeing the same critiques repeatedly, I've started catching those issues myself. The agents are training me.</li>\n<li><strong>Agent specialization matters</strong> - Each agent has one job, grounded in real methodologies.</li>\n<li><strong>Parallel review, sequential revision</strong>. Let critics run simultaneously, but revise sequentially.</li>\n<li><strong>Veto power creates accountability</strong>. Authenticity Guardian can block generic content. Skeptical Reader can block incomplete content.</li>\n<li><strong>Debates need protocols</strong> - Without clear convergence criteria, agents argue forever.</li>\n<li><strong>Meta-agents are powerful</strong> - Using <code>/agent-generator</code> and <code>/expertise</code> to create the system was way more effective than hand-writing everything.</li>\n</ul>\n<h2>The Files</h2>\n<p>Here's what the actual file structure looks like:</p>\n<p><strong>Agents</strong> (in <code>.claude/agents/</code>):</p>\n<ul>\n<li><code>blog-technical-educator/AGENT.md</code> - Creator &#x26; Reviser: Transforms conversations into drafts AND implements revisions through iterative rounds</li>\n<li><code>blog-authenticity-guardian/AGENT.md</code> - Voice critic: Ensures posts sound like Luke (catches AI patterns, corporate speak, tutorial framing)</li>\n<li><code>blog-skeptical-reader/AGENT.md</code> - Completeness &#x26; Authentic Framing critic: Catches missing context, gaps, AND inauthentic framing from past-Luke's perspective</li>\n<li><code>blog-structure-editor/AGENT.md</code> - Structure critic: Optimizes flow, hierarchy, AND authenticity of openings/natural flow</li>\n</ul>\n<p><strong>Orchestrators</strong> (in <code>.claude/commands/</code>):</p>\n<ul>\n<li><code>generate-blog-post-multi-agent.md</code> - Generate from conversation history</li>\n<li><code>review-blog-post-multi-agent.md</code> - Review existing markdown file</li>\n</ul>\n<p><strong>Meta-skills I used</strong>:</p>\n<ul>\n<li><code>/agent-generator</code> - Creates well-structured agent definitions automatically</li>\n<li><code>/expertise</code> - Synthesizes frameworks from domain experts (swyx, Julia Evans, etc.)</li>\n</ul>\n<h2>Final Thoughts</h2>\n<p>This isn't about replacing human writing. It's about having specialized reviewers who catch what I'd miss.</p>\n<p>Think of it like:</p>\n<ul>\n<li>Authenticity Guardian = Friend who knows your voice</li>\n<li>Skeptical Reader = Reader emailing \"I got stuck at step 3\"</li>\n<li>Structure Editor = Copy editor focused on flow</li>\n<li>Technical Educator = Yourself, synthesizing feedback AND implementing revisions</li>\n</ul>\n<p>All working together to ship better content through 2-3 iterative rounds, with task_id reuse maintaining conversation context so critics remember their previous feedback.</p>\n<p>The agents don't write for me. They debate with each other until what I wrote is clear, complete, and authentically mine.</p>\n<p>Now the system reviews itself.</p>\n<p>Wild. This whole process, building tools to help me write, then writing about those tools, keeps looping back on itself. I'm both the creator and the subject.</p>",
            "url": "https://lukemanning.ie/blog/building-multi-agent-blog-review-system",
            "title": "I Built a Multi-Agent System to Review My Blog Posts (And It Actually Works)",
            "summary": "<p>My blog posts were inconsistent. Some too technical. Some lost my voice. Manual reviews weren't catching enough. I needed multiple reviewers: one checking technical accuracy, one preserving my voice, one thinking like a skeptical reader.</p>\n<p><em>Note: This system was originally built with Claude Code. I've since migrated everything to Opencode, but I'm keeping original references because that's how I actually built it. The concepts transfer over. The file paths are just different now.</em></p>\n<p>So I built a multi-agent review system. Four specialized AI agents that debate each draft until it's ready to publish. Not generic AI-generated slop but actual quality control that catches what I'd miss.</p>\n<h2>TL;DR</h2>\n<p>I built a 4-agent review system that catches what I'd miss:</p>\n<ul>\n<li>Technical Educator transforms conversations → blog drafts AND implements revisions through iterative rounds</li>\n<li>Authenticity Guardian ensures it sounds like me (catches AI patterns, corporate speak, tutorial framing)</li>\n<li>Skeptical Reader catches missing context, skipped steps, AND inauthentic framing (from past-Luke's perspective)</li>\n<li>Structure Editor optimizes flow, readability, and authenticity of openings and natural flow</li>\n</ul>\n<p>The pattern is copyable. Each agent reads same file, applies different criteria, writes feedback to disk. Orchestrator coordinates parallel review, aggregate feedback, revise, and repeat until convergence (2-3 rounds with task_id reuse to maintain context).</p>\n<p>Not magic. Just file I/O and well-designed prompts. Overkill? Yes. Does it catch things I'd miss? Also yes.</p>\n<p><strong>Jump to:</strong></p>\n<ul>\n<li><a href=\"#how-agents-actually-communicate-this-confused-me-too\">How agents actually communicate</a></li>\n<li><a href=\"#the-agent-file-structure\">Complete agent definition example</a></li>\n<li><a href=\"#the-debate-protocol\">The debate protocol</a></li>\n</ul>\n<hr>\n<p>Here's what I built, how it works, and what surprised me along the way.</p>\n<h2>Context: What I Built This With</h2>\n<p>I built this with Claude Code, the CLI from Anthropic where Claude can read/write files, run commands, and maintain context across your project.</p>\n<p>I'd already been using it for a while, so I knew the directory pattern:</p>\n<ul>\n<li><strong>Agents</strong> in <code>.claude/agents/&#x3C;name>/AGENT.md</code> (specialized AI personas with evaluation frameworks)</li>\n<li><strong>Skills</strong> in <code>.claude/skills/</code> (single-purpose tools I invoke with slash commands)</li>\n<li><strong>Commands</strong> in <code>.claude/commands/</code> (orchestrators that coordinate multiple agents)</li>\n</ul>\n<p>Claude Code automatically discovers files in <code>.claude/</code>. A file at <code>.claude/commands/review-blog-post.md</code> becomes the slash command <code>/review-blog-post</code>. Simple pattern, but it took me quite a while to figure out how to chain agents together properly.</p>\n<p>I'd already built two tools before starting this project:</p>\n<ul>\n<li><code>/agent-generator</code>: Creates well-structured agent definitions automatically</li>\n<li><code>/expertise</code>: Synthesizes frameworks from domain experts to ground agents in real methodologies</li>\n</ul>\n<p>These are my custom tools—not built-in Claude Code features. I built them using the same patterns I'm about to show you.</p>\n<h2>The Problem: Quality Control at Scale</h2>\n<p>I have two different ways I create blog posts, and both of them were creating quality issues:</p>\n<p><strong>Writing myself</strong>: I'll jot down ideas over days or weeks, get a messy braindump of thoughts, then ask AI to structure it into something coherent. This works great when I've been thinking about a topic for a while—but AI would often lose my voice or turn it into a tutorial.</p>\n<p><strong>AI-generated from conversation</strong>: Sometimes I'll have a really good conversation with Claude where I learned something through debugging. Instead of rewriting it from scratch, I'll ask AI to generate a post directly from the conversation history. These were even worse. Too polished, too generic, missing the struggle.</p>\n<p>Both approaches needed serious cleanup.</p>\n<p>Both approaches create messy drafts that need work—and that's where the quality issues creep in:</p>\n<ul>\n<li>Some posts became more like tutorials instead of journey-sharing</li>\n<li>Posts would sound too polished (clearly AI-generated)</li>\n<li>I'd skip \"obvious\" steps that weren't obvious to past-me</li>\n<li>Structure would be all over the place</li>\n<li>My authentic voice would get lost in editing &#x26; review</li>\n</ul>\n<p>I needed a system that could:</p>\n<ol>\n<li>Transform my raw notes/conversations into blog drafts</li>\n<li>Catch quality issues before publishing</li>\n<li>Preserve my authentic voice</li>\n<li>Ensure completeness (no missing steps or context)</li>\n</ol>\n<p>The solution I decided to explore wsa letting specialized AI agents debate each other until they converge on something worth publishing.</p>\n<h2>The Multi-Agent Architecture</h2>\n<p>I ended up with four specialized agents, each with a specific job:</p>\n<h3>1. Technical Educator (The Creator &#x26; Reviser)</h3>\n<p><strong>Job</strong>: Transform raw conversations or notes into blog post drafts AND implement revisions based on critic feedback through iterative rounds.</p>\n<p><strong>Based on</strong>: Real methodologies from swyx (\"learn in public\"), Julia Evans (debugging narratives), Josh Comeau (mental models first), Andy Matuschak (progressive disclosure), and Anne-Laure Le Cunff (ship version 1.0).</p>\n<p><strong>File location</strong>: <code>.claude/agents/blog-technical-educator/AGENT.md</code></p>\n<p>This agent has TWO phases:</p>\n<p><strong>Phase 1 - Create Drafts</strong>:\nTakes my messy notes or conversation transcripts and structures them into:</p>\n<ul>\n<li>Opening hook (the specific problem)</li>\n<li>Story arc (my debugging journey)</li>\n<li>Mental model (how it actually works)</li>\n<li>Practical solution (what to do)</li>\n<li>Key takeaways</li>\n</ul>\n<p><strong>Phase 2 - Implement Revisions</strong>:\nAfter receiving critic feedback (overlapping concerns, conflicting input), the agent:</p>\n<ul>\n<li>Prioritizes issues (high/medium/low priority)</li>\n<li>Implements targeted revisions (not complete rewrites)</li>\n<li>Provides complete revised posts (not just suggestions)</li>\n<li>Iterates with critics for 2-3 rounds until convergence</li>\n<li>Uses stored task_ids to maintain conversation context across rounds</li>\n</ul>\n<p>The framework is grounded in actual expert approaches. Not generic \"write a blog post\" instructions.</p>\n<h3>2. Authenticity Guardian (Voice Critic)</h3>\n<p><strong>Job</strong>: Ensure posts sound like me, not generic AI content.</p>\n<p><strong>File location</strong>: <code>.claude/agents/blog-authenticity-guardian/AGENT.md</code></p>\n<p><strong>The YAML frontmatter explained</strong>: Each agent file starts with YAML metadata that tells Claude Code what model to use, which tools the agent has access to, and basic identification. The markdown content below defines the agent's expertise and protocols.</p>\n<p>This agent is ruthless about voice violations:</p>\n<p><strong>Red flags it catches</strong>:</p>\n<ul>\n<li>Corporate speak (\"leveraging,\" \"optimizing,\" \"in today's landscape\")</li>\n<li>Generic transitions that add no value</li>\n<li>Vague generalizations where specifics would fit</li>\n<li>Lecturing tone instead of sharing tone</li>\n<li>Perfect polish without personality</li>\n</ul>\n<p><strong>Example critique</strong>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">🚨 Critical violation - Corporate speak</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Problem: \"In order to optimize performance, it's recommended to</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">leverage memoization techniques.\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Authentic alternative: \"I was getting way too many re-renders.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Turns out, memoization fixed it - React stopped recalculating</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">stuff it had already figured out.\"</span></span></code></pre>\n<p>It has <strong>veto power</strong> on voice authenticity. If it doesn't sound like me, it doesn't ship.</p>\n<p><strong>How veto power works</strong>: In the convergence protocol, if Authenticity Guardian scores a post below 7/10, the Technical Educator must revise before proceeding. The orchestrator enforces this - no publication happens without voice approval.</p>\n<h3>3. Skeptical Reader (Completeness &#x26; Authentic Framing Critic)</h3>\n<p><strong>Job</strong>: Read from a beginner's perspective and find confusion points, missing context, AND inauthentic framing.</p>\n<p><strong>File location</strong>: <code>.claude/agents/blog-skeptical-reader/AGENT.md</code></p>\n<p>This agent represents my target audience: tech support engineers, junior developers, people learning in public (basically past-Luke).</p>\n<p><strong>What it flags</strong>:</p>\n<ul>\n<li>Missing version numbers or environment details</li>\n<li>Skipped steps that seem \"obvious\" to experts</li>\n<li>Logical gaps in the story (\"wait, how did we get here?\")</li>\n<li>Unanswered questions readers would have</li>\n<li>Cognitive overload (too much at once)</li>\n<li><strong>Inauthentic framing</strong>: Prescriptive \"you should\" language, generic tutorial patterns, magic jumps that skip reasoning</li>\n</ul>\n<p><strong>Example critique</strong>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">🚨 Critical Gap - Missing Context</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Problem: \"Just run the build command\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Reader questions:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Which command?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> In what directory?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> What should the output look like?</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> How do I know if it worked?</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Fix needed: Show exact command, expected output, success criteria</span></span></code></pre>\n<h3>4. Structure Editor (Flow, Readability, &#x26; Authenticity Critic)</h3>\n<p><strong>Job</strong>: Optimize structure, pacing, visual hierarchy for web reading, AND authenticity of openings/natural flow.</p>\n<p><strong>File location</strong>: <code>.claude/agents/blog-structure-editor/AGENT.md</code></p>\n<p>This agent ensures posts are designed for how people actually read on the web. Scanning, skimming, then diving deep. Also checks that openings sound like Luke (not tutorial hooks) and that the flow feels natural, not forced.</p>\n<p><strong>What it evaluates</strong>:</p>\n<ul>\n<li>Opening authenticity (sounds like Luke or tutorial hook?)</li>\n<li>Visual hierarchy (can you understand post from headings alone?)</li>\n<li>Pacing (mix of short/long paragraphs, visual breaks)</li>\n<li>Flow (smooth transitions, natural progression, not forced)</li>\n<li>Engagement (does each section pull you to the next?)</li>\n</ul>\n<p><strong>Example critique</strong>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">⚠️ Structure Issue - Weak Opening</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Problem: \"Asynchronous JavaScript is an important concept...\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Impact: Vague introduction, no hook, buried lede.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">No reason to keep reading.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Fix: Start with specific error or problem:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">\"I kept hitting </span><span style=\"color:#79B8FF\">`Cannot read property 'map' of undefined`</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">and honestly, I was stumped for hours...\"</span></span></code></pre>\n<h2>The Agent File Structure</h2>\n<p>I want to show you what an actual agent file looks like, not just describe the structure. This is the Authenticity Guardian, which I created because I kept getting AI-generated slop that sounded like documentation instead of me.</p>\n<p>Here's the file:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">description</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Ensures blog posts sound like Luke, not generic AI content</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">model</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">claude-sonnet-4</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">---</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\"># Authenticity Guardian</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">You are a specialist in authentic voice detection for Luke Manning's blog. Your job is to ensure posts sound like Luke, conversational, specific, honest about confusion, not like generic AI content or tutorials.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## What You Evaluate</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### 1. Personal vs. Generic</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Specific: \"I spent quite a while debugging...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Vague: \"This was challenging...\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Generic AI phrases to flag: \"Let's explore,\" \"In today's landscape\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### 2. Conversational vs. Corporate</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Conversational: \"I was getting way too many re-renders\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Corporate: \"In order to optimize performance, leverage memoization\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### 3. Humble Sharing vs. Expert Lecturing</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Journey framing: \"Here's what I did\"</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Instructional framing: \"You should do X\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Red Flags</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">If you see these, flag as CRITICAL:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"Let's explore,\" \"Let's dive into\" (AI signature patterns)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"It is recommended,\" \"You should\" (instructional mode)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"Leverage,\" \"Optimize\" without specific examples</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"One of the best practices is...\" (generic advice)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Overuse of transition words: \"Moreover,\" \"Furthermore,\" \"Additionally\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Scoring</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> 9-10: Unmistakably Luke</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> 7-8: Minor voice breaks, needs polish</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Below 7: Major voice violation, needs revision</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Provide specific line-by-line feedback with before/after examples.</span></span></code></pre>\n<p>This structure (evaluation framework, red flags with specific examples, clear scoring) made agents effective. I learned the hard way that vague instructions produce vague feedback.</p>\n<h2>How Agents Actually Communicate (This Confused Me Too)</h2>\n<p>Here's what I got wrong at first: I assumed agents would talk to each other directly, passing messages back and forth like a Slack channel.</p>\n<p>Nope.</p>\n<p>Agents don't communicate. They don't even know other agents exist. Each one is a completely isolated Claude session reading the same file.</p>\n<p><strong>Here's how it actually works:</strong></p>\n<ol>\n<li><strong>Orchestrator (me or a slash command) triggers the workflow</strong></li>\n<li><strong>Technical Educator reads the source</strong> (conversation transcript or existing post)</li>\n<li><strong>File gets written</strong> to disk (e.g., <code>draft-post.md</code>)</li>\n<li><strong>Three critic agents launch in parallel</strong> (separate Claude sessions via the Task tool)\n<ul>\n<li>Each reads the SAME file from disk</li>\n<li>Each applies its own review criteria</li>\n<li>Each returns structured feedback</li>\n<li><strong>IMPORTANT</strong>: Orchestrator captures <code>task_id</code> from each critic for reuse in subsequent rounds</li>\n</ul>\n</li>\n<li><strong>Orchestrator aggregates results</strong> (combines the three reviews)</li>\n<li><strong>Technical Educator gets compiled feedback</strong> (as a single prompt) + stored task_ids</li>\n<li><strong>Technical Educator revises</strong> based on feedback, provides complete revised post</li>\n<li><strong>Critics re-review using stored task_ids</strong> (maintains conversation context across rounds)</li>\n<li><strong>Repeat steps 4-8</strong> until all critics approve (or max 3 rounds hit)</li>\n</ol>\n<p>The \"communication\" is just file I/O and prompt engineering. Each agent writes its opinion, the orchestrator reads those opinions, compiles them into context for the next agent. The task_id reuse ensures critics remember their previous feedback and maintain conversation context across iterative rounds.</p>\n<p><strong>Why this matters:</strong> You don't need any fancy agent framework or message bus. Just:</p>\n<ul>\n<li>Use the Task tool to spawn agents with specific prompts</li>\n<li>Pass file paths as context</li>\n<li>Aggregate outputs in the orchestrator</li>\n<li>Feed compiled results to the next agent</li>\n</ul>\n<p><strong>In Claude Code CLI, you invoke commands like:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">/review-blog-post-multi-agent</span><span style=\"color:#9ECBFF\"> @content/posts/my-post.md</span></span></code></pre>\n<p>The orchestrator file then uses the Task tool to spawn parallel agents:</p>\n<pre><code>task(blog-skeptical-reader, \"Review this draft for completeness gaps\")\ntask(blog-authenticity-guardian, \"Check this for voice authenticity\")\ntask(blog-structure-editor, \"Evaluate structure and flow\")\n</code></pre>\n<p>That's it. That's the whole multi-agent system.</p>\n<p>The complexity isn't in the infrastructure. It's in the prompt design. Each agent needs clear evaluation frameworks, specific examples, and structured output formats. Get those right, and the orchestration is straightforward.</p>\n<h2>The Two Workflows</h2>\n<p>I built two separate orchestration workflows:</p>\n<h3>Workflow 1: Generate Blog Post from Conversation</h3>\n<p><strong>Command</strong>: Slash command (invokes skill)\n<strong>File</strong>: <code>.claude/commands/generate-blog-post-multi-agent.md</code></p>\n<p><strong>How it works</strong>:</p>\n<ol>\n<li><strong>Orchestrator analyzes</strong> last 20-40 messages in conversation</li>\n<li><strong>Extracts</strong> the story arc (problem → attempts → breakthrough → solution)</li>\n<li><strong>Identifies</strong> technical artifacts (error messages, code, version numbers)</li>\n<li><strong>Spawns Technical Educator</strong> (Phase 1) to create initial draft</li>\n<li><strong>Spawns all 3 critics in parallel</strong> to review (captures task_ids for reuse)</li>\n<li><strong>Aggregates feedback</strong> (critical issues, overlapping concerns, conflicts)</li>\n<li><strong>Technical Educator revises</strong> (Phase 2) based on feedback + provides complete revised post</li>\n<li><strong>Critics re-review using stored task_ids</strong> (2-3 rounds until convergence)</li>\n<li><strong>Delivers</strong> publication-ready markdown</li>\n</ol>\n<p><strong>What makes this work</strong>: The orchestrator maintains the core principles (learn in public, authenticity over polish, write for past-self), ensures agents stay grounded in those values, and uses task_id reuse to maintain conversation context across iterative rounds.</p>\n<h3>How Agents Actually \"Run\": What Happens Under the Hood</h3>\n<p>I spent quite a while thinking agents would talk to each other directly, passing messages like a Slack channel. Nope.</p>\n<p>Agents don't communicate. They don't even know other agents exist. Each one is a completely isolated Claude session reading the same file.</p>\n<p>Here's what actually happens when the orchestrator triggers a review:</p>\n<p><strong>The orchestrator prepares context:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">You are the Skeptical Reader from </span><span style=\"color:#79B8FF\">`.claude/agents/blog-skeptical-reader/AGENT.md`</span><span style=\"color:#E1E4E8\">.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Your task: Review this draft blog post for completeness gaps.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[Draft content here]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">Provide your review in the standard format: score, critical issues,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">medium priority, low priority, what's working well.</span></span></code></pre>\n<p><strong>Claude loads the agent definition:</strong>\nWhen the orchestrator specifies <code>task(blog-skeptical-reader, ...)</code>, Claude Code automatically loads the AGENT.md file. This file has the evaluation framework, examples of good/bad content, and output format. The orchestrator doesn't need to know what's inside—Claude Code handles it.</p>\n<p><strong>Agent responds with structured feedback:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Skeptical Reader Review</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Overall Score**</span><span style=\"color:#E1E4E8\">: 7/10</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Critical Issues (Must Fix)**</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Missing Next.js version number (line 45)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> \"Simply do X\" assumes reader knowledge (line 89)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**Medium Priority (Should Fix)**</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Vague heading \"Implementation\" → suggest \"The Fix: useState with Null Check\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8;font-weight:bold\">**What's Working Well**</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Opening hook is specific and relatable</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Code examples include error messages</span></span></code></pre>\n<p><strong>Here's the trick—each agent runs in parallel:</strong></p>\n<p>The orchestrator spawns all three critics at the exact same time. They don't know about each other, they can't see each other's feedback, and they all return results independently. This is what prevents bias contamination.</p>\n<p><strong>Orchestrator aggregates all three reviews:</strong>\nThe orchestrator waits for all three parallel agent reviews, then creates a unified feedback document.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">## Aggregated Feedback for Technical Educator</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Critical Issues (All 3 agents flagged):</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Missing version numbers (Skeptical Reader)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Corporate speak in paragraph 3 (Authenticity Guardian)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Wall of text in \"Implementation\" section (Structure Editor)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Overlapping Concerns:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Both Authenticity Guardian and Structure Editor want shorter paragraphs</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Both Skeptical Reader and Structure Editor want better headings</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\">### Approval Status:</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Authenticity Guardian: 6/10 (needs revision)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Skeptical Reader: 7/10 (needs revision)</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">-</span><span style=\"color:#E1E4E8\"> Structure Editor: 8/10 (approve after fixes)</span></span></code></pre>\n<p><strong>Technical Educator revises:</strong>\nThe orchestrator spawns Technical Educator again with the aggregated feedback. It implements fixes and documents what changed.</p>\n<p><strong>Critics re-review using stored task_ids:</strong>\nThis is the part that confused me at first. How do critics remember their previous feedback? The answer is task_id reuse. When the orchestrator first spawns each critic, it captures a <code>task_id</code> from the response. When resubmitting the revised draft, it reuses that same <code>task_id</code>. This maintains conversation context across rounds so critics remember what they flagged before.</p>\n<p>The whole multi-agent system is just file I/O and task_id reuse. Agents write opinions to disk, orchestrator reads and compiles them, feeds results to the next agent. No fancy message bus needed.</p>\n<p>This is how the orchestrator manages the entire multi-agent workflow, spawning agents sequentially or in parallel, aggregating their outputs, and enforcing convergence criteria.</p>\n<h3>Workflow 2: Review Existing Blog Post</h3>\n<p><strong>Command</strong>: <code>/review-blog-post-multi-agent @content/posts/post-slug.md</code>\n<strong>File</strong>: <code>.claude/commands/review-blog-post-multi-agent.md</code></p>\n<p><strong>How it works</strong>:</p>\n<ol>\n<li><strong>Read existing post</strong> (analyze structure, voice, completeness)</li>\n<li><strong>Review voice baseline</strong> (check 2-3 recent posts for consistency)</li>\n<li><strong>Spawn all 3 critics in parallel</strong> (captures task_ids for reuse)</li>\n<li><strong>Aggregate feedback</strong> (critical/medium/low priority)</li>\n<li><strong>Spawn Technical Educator</strong> with feedback + stored task_ids</li>\n<li><strong>Technical Educator provides complete revised post</strong> + revision summary</li>\n<li><strong>Critics re-review using stored task_ids</strong> (approve/reject/refine)</li>\n<li><strong>Converge after 2-3 rounds</strong></li>\n<li><strong>Deliver actionable recommendations</strong> with before/after text</li>\n</ol>\n<p><strong>Output includes</strong>:</p>\n<ul>\n<li>Quick wins (5-10 minute fixes)</li>\n<li>Moderate improvements (30-60 minute rewrites)</li>\n<li>Major rewrites (only if fundamental issues)</li>\n<li>What to preserve (don't change these sections)</li>\n<li>Contested items (user decides)</li>\n</ul>\n<h2>The Debate Protocol</h2>\n<p>Here's what makes this system actually work: <strong>adversarial debate with convergence</strong>.</p>\n<h3>Round 1: Initial Review</h3>\n<p>All three critics review in parallel. Each provides:</p>\n<ul>\n<li>Overall score (X/10)</li>\n<li>Critical issues (must fix)</li>\n<li>Medium priority (should fix)</li>\n<li>Low priority (nice to have)</li>\n<li>What's working well</li>\n</ul>\n<p><strong>How critics calculate scores</strong>: Each agent evaluates its 6 dimensions and assigns a score based on:</p>\n<ul>\n<li><strong>Authenticity Guardian</strong>: 9-10 = unmistakably Luke, 7-8 = minor voice breaks, below 7 = needs revision</li>\n<li><strong>Skeptical Reader</strong>: 9-10 = no gaps or confusion, 7-8 = 1-2 missing details, below 7 = critical gaps</li>\n<li><strong>Structure Editor</strong>: 9-10 = perfect flow and hierarchy, 7-8 = minor pacing issues, below 7 = structural problems</li>\n</ul>\n<p>The score isn't arbitrary - it's calculated from the count and severity of issues found across all dimensions.</p>\n<h3>Round 2: Revision</h3>\n<p>Technical Educator receives aggregated feedback + stored task_ids and:</p>\n<ul>\n<li>Addresses all critical issues</li>\n<li>Tackles medium priority items</li>\n<li>Makes judgment calls on conflicts</li>\n<li>Documents what was changed and why</li>\n<li><strong>Provides complete revised post</strong> (full markdown, not just suggestions)</li>\n</ul>\n<p>Critics re-review using stored task_ids (maintains conversation context) and either approve or escalate remaining concerns.</p>\n<h3>Round 3: Final Refinement (if needed)</h3>\n<p>For contested items or remaining gaps. By round 3, most things have converged.</p>\n<h3>Convergence Criteria</h3>\n<p>A post is ready when:</p>\n<ul>\n<li>All three critics approve</li>\n<li>Story arc is clear</li>\n<li>Mental models explained before implementation</li>\n<li>Content is specific and searchable</li>\n<li>Voice is authentic</li>\n<li>No technical inaccuracies</li>\n</ul>\n<p><strong>Convergence failure protocol</strong>: If agents can't agree after 3 rounds, document both perspectives and let me decide.</p>\n<p>Example contested issue:</p>\n<ul>\n<li>Authenticity wants rambling paragraph (authentic voice)</li>\n<li>Structure wants visual breaks (better readability)</li>\n<li><strong>Resolution</strong>: Keep the words, add paragraph breaks</li>\n</ul>\n<h3>Real Example: Reviewing This Very Post</h3>\n<p>Want to see this in action? Here's what happened when I ran this post through the system:</p>\n<p><strong>Round 1 Feedback:</strong></p>\n<p>Authenticity Guardian (6.5/10): \"Too much formal hedge-language. You used 'It's worth noting' 11 times. That's not Luke—that's documentation voice.\"</p>\n<p>Skeptical Reader (7/10): \"Where's the complete AGENT.md file? You mention agents but never show one. How do agents communicate—file I/O, API calls, what?\"</p>\n<p>Structure Editor (7/10): \"Hook buried 300 words deep. Dense 400-word paragraphs. No TL;DR for a 3,600-word post.\"</p>\n<p><strong>My revisions:</strong></p>\n<ul>\n<li>Killed all \"It's worth noting\" instances → replaced with direct statements</li>\n<li>Added complete 60-line Authenticity Guardian definition</li>\n<li>Added \"How Agents Actually Communicate\" section explaining file I/O</li>\n<li>Added TL;DR with jump links</li>\n<li>Strengthened opening hook</li>\n</ul>\n<p><strong>Round 2 Feedback:</strong></p>\n<p>Authenticity Guardian (7.5/10): Better, but needs more struggle journey\nSkeptical Reader (9/10): APPROVE—all critical gaps fixed\nStructure Editor (8/10): Major improvements, minor pacing tweaks needed</p>\n<p>That's the system working. Multiple perspectives, specific feedback, iterative improvement.</p>\n<h2>The Journey of Building This</h2>\n<h3>Starting Point: The <code>/agent-generator</code> Skill</h3>\n<p>I didn't write these agent definitions from scratch. I used a meta-skill I'd built previously: <code>/agent-generator</code>.</p>\n<p>This skill creates well-structured agent definitions by:</p>\n<ol>\n<li>Understanding the agent's role</li>\n<li>Invoking <code>/expertise</code> skill to ground in real methodologies</li>\n<li>Designing interaction protocols</li>\n<li>Defining output formats</li>\n</ol>\n<h3>The Orchestrator Pattern</h3>\n<p>The orchestrators (in <code>.claude/commands/</code>) don't contain agent logic. They:</p>\n<ul>\n<li>Prepare context</li>\n<li>Spawn agents in sequence</li>\n<li>Aggregate feedback</li>\n<li>Manage convergence</li>\n<li>Format final output</li>\n</ul>\n<p><strong>Key decision</strong>: Parallel review with sequential revision.</p>\n<p>Critics review simultaneously (faster), but revisions happen sequentially (prevents chaos).</p>\n<h3>The Expert Grounding Approach</h3>\n<p>Each agent is grounded in real methodologies:</p>\n<p><strong>Technical Educator</strong>:</p>\n<ul>\n<li>swyx: \"Learn in public\" philosophy, document the journey</li>\n<li>Julia Evans: Debugging narratives, specific error messages</li>\n<li>Josh Comeau: Mental models before implementation</li>\n<li>Andy Matuschak: Progressive disclosure (simple → complex)</li>\n<li>Anne-Laure Le Cunff: Ship version 1.0, iterate</li>\n</ul>\n<p><strong>Authenticity Guardian</strong>:</p>\n<ul>\n<li>Content strategy voice analysis</li>\n<li>AI detection patterns</li>\n<li>Brand alignment frameworks</li>\n</ul>\n<p><strong>Skeptical Reader</strong>:</p>\n<ul>\n<li>Cognitive load theory</li>\n<li>Curse of knowledge awareness</li>\n<li>Technical documentation best practices</li>\n</ul>\n<p><strong>Structure Editor</strong>:</p>\n<ul>\n<li>Inverted pyramid (journalism)</li>\n<li>Web reading behavior (F-pattern scanning)</li>\n<li>Readability frameworks (Flesch-Kincaid)</li>\n</ul>\n<p>This grounding prevents generic \"AI helping AI\" nonsense. Each agent has real frameworks to reference.</p>\n<h2>What Didn't Work (And Why)</h2>\n<h3>Attempt 1: Single Agent Doing All Three Jobs</h3>\n<p>I started optimistically, one agent to rule them all. Check voice, completeness, and structure all in one pass. Seemed efficient.</p>\n<p><strong>What actually happened</strong>:\nThe agent would catch voice issues but miss missing code examples. Or notice structural problems but completely gloss over authenticity breaks. It was like asking one person to be a copy editor, a fact-checker, and a voice coach simultaneously. Something always got missed.</p>\n<p><strong>Why it failed</strong>:\nToo many competing objectives. The agent couldn't specialize. Trying to hold three different evaluation frameworks at once meant it couldn't apply any of them well. I kept tweaking the prompt, thinking the issue was in how I phrased things. Spent quite a while before realizing the real problem was the architecture itself.</p>\n<h3>Attempt 2: Sequential Review (Voice → Skeptical → Structure)</h3>\n<p>Okay, split them up. Run Voice first, then Skeptical, then Structure. Each agent sees the previous agent's feedback and builds on it.</p>\n<p><strong>What actually happened</strong>:\nThe Structure Editor would see \"Voice score: 6/10\" and unconsciously lower its own standards. Or Skeptical Reader would notice Authenticity Guardian flagged something as critical, then ignore a similar issue because \"that's already being addressed.\"</p>\n<p><strong>Why it failed</strong>:\nTwo problems:</p>\n<p>First: Bias contamination. Agents were influenced by each other's scores instead of evaluating independently.</p>\n<p>Second: Terrible performance. Three sequential Claude calls meant 30+ seconds of waiting for each review. I'd run a post through the system, go grab coffee, come back, and still be waiting on the third agent. Not sustainable.</p>\n<p>I thought sequential would be better, each agent could learn from the previous one. Instead, it just created echo chambers where agents converged on \"good enough\" instead of pushing for better.</p>\n<h2>What Worked: The Breakthrough</h2>\n<h3>Attempt 3: Parallel Review (Current System)</h3>\n<p>Launch all three critics at once, each reviewing independently. Aggregate results afterward.</p>\n<p><strong>Why this worked</strong>:</p>\n<ul>\n<li>No bias contamination—agents can't see each other's feedback</li>\n<li>Faster execution (parallel API calls)</li>\n<li>Agents can disagree, which surfaces interesting edge cases</li>\n<li>Technical Educator gets unfiltered input from all perspectives</li>\n</ul>\n<p>The breakthrough was realizing that disagreement is valuable. When Authenticity Guardian wants rambling paragraphs (authentic voice) and Structure Editor wants visual breaks (readability), that tension forces the Technical Educator to find creative solutions like keeping the words but adding paragraph breaks.</p>\n<h2>What Surprised Me</h2>\n<h3>Convergence Happens Faster Than Expected</h3>\n<p>Most posts converge in 2 rounds:</p>\n<ul>\n<li>Round 1: 5-10 issues flagged</li>\n<li>Round 2: All addressed, critics review again</li>\n<li>Round 3: Final adjustments and polish</li>\n</ul>\n<p>Going past round 3 is rare (only for major rewrites or contested items).</p>\n<h3>The System Catches Things I'd Miss</h3>\n<p><strong>Example from recent review</strong>:</p>\n<ul>\n<li>Missing Next.js version number</li>\n<li>\"Simply do X\" (curse of knowledge)</li>\n<li>Vague heading \"Implementation\" → Changed to \"The Fix: useState with Null Check\"</li>\n<li>Wall of text (350 words, no breaks) → Split into 3 paragraphs with code block</li>\n</ul>\n<p>All things I'd probably ship without noticing.</p>\n<h3>Voice Preservation Actually Works</h3>\n<p>The Authenticity Guardian is brutal but accurate. It catches:</p>\n<ul>\n<li>Corporate buzzwords I'd unconsciously use</li>\n<li>Generic transition phrases (\"Let's explore...\")</li>\n<li>Expert assumptions (\"Obviously you'll need to...\")</li>\n<li>Common AI phrases (\"But honestly?\")</li>\n</ul>\n<p>And it suggests authentic alternatives that sound like me:</p>\n<ul>\n<li>\"I kept hitting this error for two hours...\"</li>\n<li>\"Turns out, the issue was...\"</li>\n<li>\"Here's what surprised me...\"</li>\n</ul>\n<h2>The Results</h2>\n<p>All of this sounds great on paper. Does it actually work?</p>\n<p>Honestly, I wasn't sure at first. The first few runs were slow. I kept tweaking prompts, adjusting scoring thresholds, chasing edge cases where agents would argue forever.</p>\n<p>But after a while, the system settled in. Here's what I've observed:</p>\n<p><strong>Quality improvement</strong>: The agents consistently catch things I'd miss, voice breaks, missing version numbers, and obvious steps that aren't obvious at all</p>\n<p><strong>Consistency</strong>: Every post follows the same quality bar now, which wasn't true when I was reviewing manually</p>\n<p><strong>Learning</strong>: The critic feedback teaches me what to avoid in future writing</p>\n<p>Not perfect data, I haven't been tracking this scientifically. But qualitatively, it's way better than my manual reviews.</p>\n<hr>\n<p>These results didn't come from magic. They came from careful design. Here's what's under the hood: file structure, agent definitions, and orchestration patterns that make this work.</p>\n<h2>Agent Structure (High-Level)</h2>\n<p>Each agent follows the same pattern:</p>\n<p><strong>File</strong>: <code>.opencode/agent/&#x3C;agent-name>.md</code></p>\n<p><strong>Core components</strong>:</p>\n<ul>\n<li>YAML frontmatter (description, tools, permissions)</li>\n<li>Mission statement</li>\n<li>Evaluation framework (specific dimensions to check)</li>\n<li>Interaction protocol (how it works with other agents)</li>\n<li>Output format (structured feedback)</li>\n<li>Examples of good/bad content</li>\n</ul>\n<p><strong>What makes this work</strong>:</p>\n<ul>\n<li>Clear job description (one specialty per agent)</li>\n<li>Grounded in real methodologies (not \"help write blog\")</li>\n<li>Specific patterns to flag (not vague \"check quality\")</li>\n<li>Structured output (orchestrator can parse it)</li>\n</ul>\n<p>The orchestrator coordinates workflow: prepare context, spawn agents, aggregate feedback, manage convergence, format output. Agents contain all evaluation logic, and their specialization is what makes the system work.</p>\n<h3>Six-Dimensional Review Frameworks</h3>\n<p>Each critic evaluates across 6 specific dimensions:</p>\n<p><strong>Authenticity Guardian</strong>:</p>\n<ol>\n<li>Personal vs. Generic</li>\n<li>Humble Sharing vs. Expert Lecturing</li>\n<li>Specific vs. Vague</li>\n<li>Conversational vs. Corporate</li>\n<li>Brand Alignment</li>\n<li>AI Detection Signals</li>\n</ol>\n<p><strong>Skeptical Reader</strong>:</p>\n<ol>\n<li>Completeness</li>\n<li>Context</li>\n<li>Gaps</li>\n<li>Questions</li>\n<li>Cognitive Load</li>\n<li>Curse of Knowledge</li>\n</ol>\n<p><strong>Structure Editor</strong>:</p>\n<ol>\n<li>Opening Hook</li>\n<li>Visual Hierarchy</li>\n<li>Pacing</li>\n<li>Flow</li>\n<li>Engagement</li>\n<li>Web Readability</li>\n</ol>\n<p>Each dimension has clear examples of good/bad and specific things to flag.</p>\n<h2>What's Next</h2>\n<h3>Immediate Improvements</h3>\n<ol>\n<li><strong>Internal linking agent</strong>: Automatically suggest links to related posts</li>\n<li><strong>SEO optimizer</strong>: Ensure titles/descriptions hit character limits</li>\n<li><strong>Code validator</strong>: Run code examples to ensure they actually work</li>\n</ol>\n<h3>Long-term Vision</h3>\n<ol>\n<li><strong>Feedback loop</strong>: Track which posts get \"I got stuck at X\" comments, feed that back to Skeptical Reader</li>\n<li><strong>Style evolution</strong>: Let agents learn from high-performing posts</li>\n<li><strong>Topic suggester</strong>: Analyze conversations to identify blog-worthy moments</li>\n</ol>\n<h3>Meta-Learning</h3>\n<p>This whole process is itself blog-worthy content. I'm using the system to review this post about building the system.</p>\n<p><strong>Inception</strong>: The agents are currently debating this very post you're reading.</p>\n<h2>Key Takeaways</h2>\n<ul>\n<li><strong>Multi-agent systems work when agents have real expertise</strong> - Don't just spawn \"helper agents.\" Ground them in actual frameworks and methodologies.</li>\n<li><strong>Adversarial debate makes better posts</strong>. The friction between Authenticity Guardian and Structure Editor leads to posts that are both genuine and readable.</li>\n<li><strong>Convergence protocols prevent endless iteration</strong>. 2-3 rounds with clear approval criteria. After that, ship or document the trade-off.</li>\n<li><strong>Orchestrators maintain principles</strong>. The orchestrator's job is reminding agents of core values: learn in public, authenticity over polish, write for past-self.</li>\n<li><strong>The system teaches you</strong>. After seeing the same critiques repeatedly, I've started catching those issues myself. The agents are training me.</li>\n<li><strong>Agent specialization matters</strong> - Each agent has one job, grounded in real methodologies.</li>\n<li><strong>Parallel review, sequential revision</strong>. Let critics run simultaneously, but revise sequentially.</li>\n<li><strong>Veto power creates accountability</strong>. Authenticity Guardian can block generic content. Skeptical Reader can block incomplete content.</li>\n<li><strong>Debates need protocols</strong> - Without clear convergence criteria, agents argue forever.</li>\n<li><strong>Meta-agents are powerful</strong> - Using <code>/agent-generator</code> and <code>/expertise</code> to create the system was way more effective than hand-writing everything.</li>\n</ul>\n<h2>The Files</h2>\n<p>Here's what the actual file structure looks like:</p>\n<p><strong>Agents</strong> (in <code>.claude/agents/</code>):</p>\n<ul>\n<li><code>blog-technical-educator/AGENT.md</code> - Creator &#x26; Reviser: Transforms conversations into drafts AND implements revisions through iterative rounds</li>\n<li><code>blog-authenticity-guardian/AGENT.md</code> - Voice critic: Ensures posts sound like Luke (catches AI patterns, corporate speak, tutorial framing)</li>\n<li><code>blog-skeptical-reader/AGENT.md</code> - Completeness &#x26; Authentic Framing critic: Catches missing context, gaps, AND inauthentic framing from past-Luke's perspective</li>\n<li><code>blog-structure-editor/AGENT.md</code> - Structure critic: Optimizes flow, hierarchy, AND authenticity of openings/natural flow</li>\n</ul>\n<p><strong>Orchestrators</strong> (in <code>.claude/commands/</code>):</p>\n<ul>\n<li><code>generate-blog-post-multi-agent.md</code> - Generate from conversation history</li>\n<li><code>review-blog-post-multi-agent.md</code> - Review existing markdown file</li>\n</ul>\n<p><strong>Meta-skills I used</strong>:</p>\n<ul>\n<li><code>/agent-generator</code> - Creates well-structured agent definitions automatically</li>\n<li><code>/expertise</code> - Synthesizes frameworks from domain experts (swyx, Julia Evans, etc.)</li>\n</ul>\n<h2>Final Thoughts</h2>\n<p>This isn't about replacing human writing. It's about having specialized reviewers who catch what I'd miss.</p>\n<p>Think of it like:</p>\n<ul>\n<li>Authenticity Guardian = Friend who knows your voice</li>\n<li>Skeptical Reader = Reader emailing \"I got stuck at step 3\"</li>\n<li>Structure Editor = Copy editor focused on flow</li>\n<li>Technical Educator = Yourself, synthesizing feedback AND implementing revisions</li>\n</ul>\n<p>All working together to ship better content through 2-3 iterative rounds, with task_id reuse maintaining conversation context so critics remember their previous feedback.</p>\n<p>The agents don't write for me. They debate with each other until what I wrote is clear, complete, and authentically mine.</p>\n<p>Now the system reviews itself.</p>\n<p>Wild. This whole process, building tools to help me write, then writing about those tools, keeps looping back on itself. I'm both the creator and the subject.</p>",
            "date_modified": "2025-12-28T00:00:00.000Z",
            "tags": [
                "ai"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/tailwind-typography-plugin-troubles",
            "content_html": "<blockquote>\n<p><strong>Note:</strong> This post was written before I redesigned my site with the terminal aesthetic. My color system has since changed from <span class=\"text-violet-400\">quantum-violet</span> and <span class=\"text-amber-400\">automation-amber</span> to <code>terminal-primary</code> and <code>terminal-accent</code>, and from <span class=\"text-slate-400\">neural-silver</span> and <span class=\"text-slate-700\">code-carbon</span> to <code>terminal-dim</code> and <code>terminal-bg</code>. The concepts and code are still valid, but the color names are outdated.</p>\n</blockquote>\n<p>I had a simple problem: bullet points weren't showing up in my blog posts. The solution seemed obvious. Install <code>@tailwindcss/typography</code>, add the <code>prose</code> class, done. Except it wasn't done. It broke everything else.</p>\n<p>This was during the early days of setting up my Velite/Next.js blog — the same period that produced <a href=\"/blog/adding-syntax-highlighting-shiki\">my syntax highlighting post</a> and the <a href=\"/blog/setting-up-velite-nextjs-revised\">Velite setup guide</a> if you want the full context.</p>\n<p>My <span class=\"text-violet-400\">quantum-violet</span> headings? Gone. My carefully styled inline code blocks? Illegible black-on-black text. The <span class=\"text-amber-400\">automation-amber</span> links I spent time on? Also overwritten. The bullets appeared, sure, but at what cost?</p>\n<p>Here's what I learned about Tailwind plugins, CSS specificity, and why sometimes the simple solution is better than the \"official\" one.</p>\n<h2>The Original Problem</h2>\n<p>I was rendering markdown blog posts in Next.js using Velite. The HTML was generating fine, but lists had no bullets:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> dangerouslySetInnerHTML</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{{ __html: post.content }} /></span></span></code></pre>\n<p>The issue? <strong>Tailwind's base reset styles strip all default list styling.</strong> No bullets, no numbers, no margins. Just plain text where lists should be.</p>\n<h2>The \"Official\" Solution That Wasn't</h2>\n<p>Every search result pointed to the same answer: install <code>@tailwindcss/typography</code> and use the <code>prose</code> class. It's literally built for styling markdown content. Perfect, right?</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">npm</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#79B8FF\"> -D</span><span style=\"color:#9ECBFF\"> @tailwindcss/typography</span></span></code></pre>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">@import</span><span style=\"color:#9ECBFF\"> \"tailwindcss\"</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">@plugin</span><span style=\"color:#E1E4E8\"> \"@tailwindcss/typography\";</span></span></code></pre>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"prose prose-lg\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  dangerouslySetInnerHTML</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{{ __html: post.content }}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">/></span></span></code></pre>\n<p>And it worked! The bullets appeared. But then I actually <em>looked</em> at the page.</p>\n<h2>What the Typography Plugin Broke</h2>\n<p><strong>1. Headings Lost Their Brand Color</strong></p>\n<p>I had spent time setting up my brand colors. <span class=\"text-violet-400\">quantum-violet</span> for headings, <span class=\"text-amber-400\">automation-amber</span> for links. All defined in my <code>globals.css</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">@layer</span><span style=\"color:#E1E4E8\"> base {</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  h1</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#85E89D\">h2</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#85E89D\">h3</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> text-violet-</span><span style=\"color:#E1E4E8\">400 </span><span style=\"color:#79B8FF\">mt-</span><span style=\"color:#E1E4E8\">2 </span><span style=\"color:#79B8FF\">mb-</span><span style=\"color:#E1E4E8\">2;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>The <code>prose</code> class overrode all of it. Headings were now default gray. My carefully crafted brand identity? Ignored.</p>\n<p><strong>2. Links Lost Their Brand Color Too</strong></p>\n<p>The same thing happened with my <span class=\"text-amber-400\">automation-amber</span> links. I had specific link styling in my base styles, but the typography plugin came with its own <code>--tw-prose-links</code> color and underlined everything by default. Gone was my subtle hover effect. In its place: the plugin's opinionated blue-purple link scheme.</p>\n<p><strong>3. Inline Code Became Unreadable</strong></p>\n<p>I had custom styling for inline code. <span class=\"text-slate-400\">neural-silver</span> text on a <span class=\"text-slate-700\">code-carbon</span> background:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-slate-</span><span style=\"color:#E1E4E8\">700 </span><span style=\"color:#79B8FF\">text-slate-</span><span style=\"color:#E1E4E8\">400 </span><span style=\"color:#79B8FF\">px-</span><span style=\"color:#E1E4E8\">1.5 </span><span style=\"color:#79B8FF\">py-</span><span style=\"color:#E1E4E8\">0.5 </span><span style=\"color:#79B8FF\">rounded</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>The typography plugin decided code should have a light background with dark text. Except my site has theme variables, and in certain contexts, this created black text on a near-black background. Completely illegible.</p>\n<p><strong>4. The Plugin Added Its Own Opinions</strong></p>\n<p>The typography plugin adds backticks around inline code (<code>content: \"\\</code>\"`), specific link colors, and a bunch of other opinionated styles. Great if you want the default prose styling. Not great if you've already built a custom design system.</p>\n<h2>Trying to Override the Plugin</h2>\n<p>My first instinct was to customize the prose styles through the plugin's CSS custom properties. The docs showed you could override defaults like <code>--tw-prose-headings</code> and <code>--tw-prose-links</code>. This seemed like the intended solution. Why else would they expose these variables?</p>\n<p>I added to my globals.css:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">.prose</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  --tw-prose-headings</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">#6b46c1</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  --tw-prose-links</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">#f59e0b</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  --tw-prose-code</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">#8b9dc3</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  --tw-prose-pre-bg</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">#1f2937</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Refreshed the page. Nothing changed. The variables weren't being picked up, or something else had higher specificity. My headings were still gray.</p>\n<p>Next attempt: match the plugin's exact selector pattern. I inspected the computed styles and saw it was using complex <code>:where()</code> selectors. So I tried to beat it at its own game:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">.prose</span><span style=\"color:#B392F0\"> :where</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#85E89D\">h1</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#85E89D\">h2</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#85E89D\">h3</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#B392F0\">:not</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">:where</span><span style=\"color:#E1E4E8\">([</span><span style=\"color:#B392F0\">class</span><span style=\"color:#F97583\">~=</span><span style=\"color:#9ECBFF\">\"not-prose\"</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#85E89D\">*</span><span style=\"color:#E1E4E8\">)) {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  color</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">#6b46c1</span><span style=\"color:#F97583\"> !important</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Still nothing. I moved it outside the <code>@layer base</code> block. I tried adding it directly to the component's style prop. I even looked at the plugin's source code to see what I was up against.</p>\n<p>The plugin was winning every specificity battle. I was spending more time fighting it than I would have spent just writing the CSS from scratch.</p>\n<h2>The Realization</h2>\n<p>I was fighting the plugin. Customizing every single element to match my existing styles. Adding <code>!important</code> everywhere. Writing increasingly complex selectors.</p>\n<p>And then it hit me: <strong>I don't need a plugin to add bullet points.</strong></p>\n<p>The typography plugin is solving a complex problem. Styling arbitrary markdown content when you don't have custom styles. But I <em>do</em> have custom styles. For everything except lists.</p>\n<h2>The Actual Solution</h2>\n<p>I removed the typography plugin entirely:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">npm</span><span style=\"color:#9ECBFF\"> uninstall</span><span style=\"color:#9ECBFF\"> @tailwindcss/typography</span></span></code></pre>\n<p>And added exactly what I needed. List styles scoped to my article container. Since my blog posts render inside <code>&#x3C;article></code> tags anyway, scoping to <code>article</code> kept things contained:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">@layer</span><span style=\"color:#E1E4E8\"> base {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  /* All my existing styles stay unchanged */</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  article</span><span style=\"color:#85E89D\"> ul</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    list-style-type</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">disc</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-left</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-top</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-bottom</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  article</span><span style=\"color:#85E89D\"> ol</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    list-style-type</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">decimal</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-left</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-top</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-bottom</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  article</span><span style=\"color:#85E89D\"> li</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-top</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">0.5</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-bottom</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">0.5</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  article</span><span style=\"color:#85E89D\"> p</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-top</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-bottom</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Then removed the <code>prose</code> class from my template. The div with <code>dangerouslySetInnerHTML</code> lives inside an <code>&#x3C;article></code> element in my page layout, so the scoped styles apply automatically:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mt-6 max-w-none\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  dangerouslySetInnerHTML</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{{ __html: post.content }}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">/></span></span></code></pre>\n<p>That's it. Ten lines of CSS. No plugin. No specificity wars. No overriding default styles I didn't want in the first place.</p>\n<h2>What I'd Tell Past-Me</h2>\n<p>Looking back at this whole mess, here's what I wish I'd understood from the start:</p>\n<p>The typography plugin is genuinely excellent. It's just solving a different problem than the one I had. It assumes you're starting fresh. Zero existing styles, want nice-looking prose out of the box. That wasn't me. I already had a design system, I just needed lists to look like lists.</p>\n<p>I also underestimated how different Tailwind v4's plugin architecture is. The old tricks for overriding plugin styles (CSS custom properties, selector matching, even <code>!important</code>) didn't work the way they used to. I was fighting against a framework behavior I didn't understand yet.</p>\n<p>The real mistake was assuming the \"official\" solution was the right solution. It solved the wrong problem in the most complicated way possible.</p>\n<h2>When the Plugin Would Have Made Sense</h2>\n<p>I'm not saying the typography plugin is bad. I probably would have reached for it if I was building a new blog without any existing styles. It does a great job of making markdown look decent with minimal effort.</p>\n<p>The times I could see myself using it:</p>\n<ul>\n<li>Before I'd spent time on custom styling (just wanted something that worked)</li>\n<li>When I needed to prototype quickly and iterate on content first</li>\n<li>When styling user-generated markdown where I couldn't predict the HTML structure</li>\n</ul>\n<p>But once you've got brand colors, custom code styling, and specific link behaviors? You're already fighting the plugin's defaults. The \"simple\" solution stops being simple.</p>\n<hr>\n<p><strong>Have you had plugin conflicts mess up your carefully crafted styles?</strong> I'd love to hear your CSS specificity war stories. What plugins have caused you the most headaches?</p>",
            "url": "https://lukemanning.ie/blog/tailwind-typography-plugin-troubles",
            "title": "Installing @tailwindcss/typography Was Not The Solution I Needed",
            "summary": "<blockquote>\n<p><strong>Note:</strong> This post was written before I redesigned my site with the terminal aesthetic. My color system has since changed from <span class=\"text-violet-400\">quantum-violet</span> and <span class=\"text-amber-400\">automation-amber</span> to <code>terminal-primary</code> and <code>terminal-accent</code>, and from <span class=\"text-slate-400\">neural-silver</span> and <span class=\"text-slate-700\">code-carbon</span> to <code>terminal-dim</code> and <code>terminal-bg</code>. The concepts and code are still valid, but the color names are outdated.</p>\n</blockquote>\n<p>I had a simple problem: bullet points weren't showing up in my blog posts. The solution seemed obvious. Install <code>@tailwindcss/typography</code>, add the <code>prose</code> class, done. Except it wasn't done. It broke everything else.</p>\n<p>This was during the early days of setting up my Velite/Next.js blog — the same period that produced <a href=\"/blog/adding-syntax-highlighting-shiki\">my syntax highlighting post</a> and the <a href=\"/blog/setting-up-velite-nextjs-revised\">Velite setup guide</a> if you want the full context.</p>\n<p>My <span class=\"text-violet-400\">quantum-violet</span> headings? Gone. My carefully styled inline code blocks? Illegible black-on-black text. The <span class=\"text-amber-400\">automation-amber</span> links I spent time on? Also overwritten. The bullets appeared, sure, but at what cost?</p>\n<p>Here's what I learned about Tailwind plugins, CSS specificity, and why sometimes the simple solution is better than the \"official\" one.</p>\n<h2>The Original Problem</h2>\n<p>I was rendering markdown blog posts in Next.js using Velite. The HTML was generating fine, but lists had no bullets:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> dangerouslySetInnerHTML</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{{ __html: post.content }} /></span></span></code></pre>\n<p>The issue? <strong>Tailwind's base reset styles strip all default list styling.</strong> No bullets, no numbers, no margins. Just plain text where lists should be.</p>\n<h2>The \"Official\" Solution That Wasn't</h2>\n<p>Every search result pointed to the same answer: install <code>@tailwindcss/typography</code> and use the <code>prose</code> class. It's literally built for styling markdown content. Perfect, right?</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">npm</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#79B8FF\"> -D</span><span style=\"color:#9ECBFF\"> @tailwindcss/typography</span></span></code></pre>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">@import</span><span style=\"color:#9ECBFF\"> \"tailwindcss\"</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">@plugin</span><span style=\"color:#E1E4E8\"> \"@tailwindcss/typography\";</span></span></code></pre>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"prose prose-lg\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  dangerouslySetInnerHTML</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{{ __html: post.content }}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">/></span></span></code></pre>\n<p>And it worked! The bullets appeared. But then I actually <em>looked</em> at the page.</p>\n<h2>What the Typography Plugin Broke</h2>\n<p><strong>1. Headings Lost Their Brand Color</strong></p>\n<p>I had spent time setting up my brand colors. <span class=\"text-violet-400\">quantum-violet</span> for headings, <span class=\"text-amber-400\">automation-amber</span> for links. All defined in my <code>globals.css</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">@layer</span><span style=\"color:#E1E4E8\"> base {</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  h1</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#85E89D\">h2</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#85E89D\">h3</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> text-violet-</span><span style=\"color:#E1E4E8\">400 </span><span style=\"color:#79B8FF\">mt-</span><span style=\"color:#E1E4E8\">2 </span><span style=\"color:#79B8FF\">mb-</span><span style=\"color:#E1E4E8\">2;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>The <code>prose</code> class overrode all of it. Headings were now default gray. My carefully crafted brand identity? Ignored.</p>\n<p><strong>2. Links Lost Their Brand Color Too</strong></p>\n<p>The same thing happened with my <span class=\"text-amber-400\">automation-amber</span> links. I had specific link styling in my base styles, but the typography plugin came with its own <code>--tw-prose-links</code> color and underlined everything by default. Gone was my subtle hover effect. In its place: the plugin's opinionated blue-purple link scheme.</p>\n<p><strong>3. Inline Code Became Unreadable</strong></p>\n<p>I had custom styling for inline code. <span class=\"text-slate-400\">neural-silver</span> text on a <span class=\"text-slate-700\">code-carbon</span> background:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-slate-</span><span style=\"color:#E1E4E8\">700 </span><span style=\"color:#79B8FF\">text-slate-</span><span style=\"color:#E1E4E8\">400 </span><span style=\"color:#79B8FF\">px-</span><span style=\"color:#E1E4E8\">1.5 </span><span style=\"color:#79B8FF\">py-</span><span style=\"color:#E1E4E8\">0.5 </span><span style=\"color:#79B8FF\">rounded</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>The typography plugin decided code should have a light background with dark text. Except my site has theme variables, and in certain contexts, this created black text on a near-black background. Completely illegible.</p>\n<p><strong>4. The Plugin Added Its Own Opinions</strong></p>\n<p>The typography plugin adds backticks around inline code (<code>content: \"\\</code>\"`), specific link colors, and a bunch of other opinionated styles. Great if you want the default prose styling. Not great if you've already built a custom design system.</p>\n<h2>Trying to Override the Plugin</h2>\n<p>My first instinct was to customize the prose styles through the plugin's CSS custom properties. The docs showed you could override defaults like <code>--tw-prose-headings</code> and <code>--tw-prose-links</code>. This seemed like the intended solution. Why else would they expose these variables?</p>\n<p>I added to my globals.css:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">.prose</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  --tw-prose-headings</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">#6b46c1</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  --tw-prose-links</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">#f59e0b</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  --tw-prose-code</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">#8b9dc3</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  --tw-prose-pre-bg</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">#1f2937</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Refreshed the page. Nothing changed. The variables weren't being picked up, or something else had higher specificity. My headings were still gray.</p>\n<p>Next attempt: match the plugin's exact selector pattern. I inspected the computed styles and saw it was using complex <code>:where()</code> selectors. So I tried to beat it at its own game:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">.prose</span><span style=\"color:#B392F0\"> :where</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#85E89D\">h1</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#85E89D\">h2</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#85E89D\">h3</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#B392F0\">:not</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">:where</span><span style=\"color:#E1E4E8\">([</span><span style=\"color:#B392F0\">class</span><span style=\"color:#F97583\">~=</span><span style=\"color:#9ECBFF\">\"not-prose\"</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#85E89D\">*</span><span style=\"color:#E1E4E8\">)) {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  color</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">#6b46c1</span><span style=\"color:#F97583\"> !important</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Still nothing. I moved it outside the <code>@layer base</code> block. I tried adding it directly to the component's style prop. I even looked at the plugin's source code to see what I was up against.</p>\n<p>The plugin was winning every specificity battle. I was spending more time fighting it than I would have spent just writing the CSS from scratch.</p>\n<h2>The Realization</h2>\n<p>I was fighting the plugin. Customizing every single element to match my existing styles. Adding <code>!important</code> everywhere. Writing increasingly complex selectors.</p>\n<p>And then it hit me: <strong>I don't need a plugin to add bullet points.</strong></p>\n<p>The typography plugin is solving a complex problem. Styling arbitrary markdown content when you don't have custom styles. But I <em>do</em> have custom styles. For everything except lists.</p>\n<h2>The Actual Solution</h2>\n<p>I removed the typography plugin entirely:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">npm</span><span style=\"color:#9ECBFF\"> uninstall</span><span style=\"color:#9ECBFF\"> @tailwindcss/typography</span></span></code></pre>\n<p>And added exactly what I needed. List styles scoped to my article container. Since my blog posts render inside <code>&#x3C;article></code> tags anyway, scoping to <code>article</code> kept things contained:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">@layer</span><span style=\"color:#E1E4E8\"> base {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  /* All my existing styles stay unchanged */</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  article</span><span style=\"color:#85E89D\"> ul</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    list-style-type</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">disc</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-left</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-top</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-bottom</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  article</span><span style=\"color:#85E89D\"> ol</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    list-style-type</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">decimal</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-left</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-top</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-bottom</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  article</span><span style=\"color:#85E89D\"> li</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-top</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">0.5</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-bottom</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">0.5</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  article</span><span style=\"color:#85E89D\"> p</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-top</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    margin-bottom</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#F97583\">rem</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Then removed the <code>prose</code> class from my template. The div with <code>dangerouslySetInnerHTML</code> lives inside an <code>&#x3C;article></code> element in my page layout, so the scoped styles apply automatically:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mt-6 max-w-none\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  dangerouslySetInnerHTML</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{{ __html: post.content }}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">/></span></span></code></pre>\n<p>That's it. Ten lines of CSS. No plugin. No specificity wars. No overriding default styles I didn't want in the first place.</p>\n<h2>What I'd Tell Past-Me</h2>\n<p>Looking back at this whole mess, here's what I wish I'd understood from the start:</p>\n<p>The typography plugin is genuinely excellent. It's just solving a different problem than the one I had. It assumes you're starting fresh. Zero existing styles, want nice-looking prose out of the box. That wasn't me. I already had a design system, I just needed lists to look like lists.</p>\n<p>I also underestimated how different Tailwind v4's plugin architecture is. The old tricks for overriding plugin styles (CSS custom properties, selector matching, even <code>!important</code>) didn't work the way they used to. I was fighting against a framework behavior I didn't understand yet.</p>\n<p>The real mistake was assuming the \"official\" solution was the right solution. It solved the wrong problem in the most complicated way possible.</p>\n<h2>When the Plugin Would Have Made Sense</h2>\n<p>I'm not saying the typography plugin is bad. I probably would have reached for it if I was building a new blog without any existing styles. It does a great job of making markdown look decent with minimal effort.</p>\n<p>The times I could see myself using it:</p>\n<ul>\n<li>Before I'd spent time on custom styling (just wanted something that worked)</li>\n<li>When I needed to prototype quickly and iterate on content first</li>\n<li>When styling user-generated markdown where I couldn't predict the HTML structure</li>\n</ul>\n<p>But once you've got brand colors, custom code styling, and specific link behaviors? You're already fighting the plugin's defaults. The \"simple\" solution stops being simple.</p>\n<hr>\n<p><strong>Have you had plugin conflicts mess up your carefully crafted styles?</strong> I'd love to hear your CSS specificity war stories. What plugins have caused you the most headaches?</p>",
            "date_modified": "2025-12-09T00:00:00.000Z",
            "tags": [
                "nextjs"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/asus-router-setup-journey",
            "content_html": "<p>I bought an Asus RT-BE88U router because my ISP-provided Fritzbox WiFi was, to put it mildly, absolute garbage. What I thought would be a simple \"plug it in and go\" situation turned into a two-hour troubleshooting adventure that taught me more about networking than I ever wanted to know.</p>\n<p>This is part of a pattern I notice in my work: <a href=\"/blog/unraid-docker-label-fix\">home server maintenance, recovery tasks, and infrastructure projects</a> don't always go smoothly, but they're worth documenting.</p>\n<p>This is that story. No pretending I knew what I was doing. Just the actual messy process of figuring it out.</p>\n<h2>The Starting Point</h2>\n<p><strong>What I had:</strong></p>\n<ul>\n<li>Brand new Asus RT-BE88U still in the box</li>\n<li>Fritzbox modem/router combo from Digiweb (my ISP)</li>\n<li>Shockingly bad WiFi coverage</li>\n<li>Confidence that this would be easy</li>\n</ul>\n<p><strong>What I knew about networking:</strong></p>\n<ul>\n<li>Enterprise troubleshooting, VLANs, routing protocols, complex network issues</li>\n<li>Consumer router setups behind ISP modems? Not my area</li>\n</ul>\n<h2>Step 1: Understanding Double NAT (Or Not Understanding It)</h2>\n<p>The first thing I learned is that my Fritzbox isn't just a modem - it's a router too. Which means when I add the Asus, I'm creating what's called \"double NAT.\"</p>\n<p>When someone first explained this to me, I got a whole technical breakdown about Network Address Translation and routing tables and... honestly, this is all enterprise networking stuff I deal with daily. But I needed it in consumer terms.</p>\n<p>As I understand it now: both routers are translating network addresses. The Fritzbox translates from your ISP's public IP to its internal network (192.168.178.x), then the Asus translates again to its own network (192.168.50.x). Packets go through two address translations before reaching the internet.</p>\n<p>At this point, I was worried this was going to cause problems. Everything I read said \"double NAT is bad\" but nobody could really explain why in a way I understood. Gaming? Servers? I don't do any of that.</p>\n<p>I decided to just try it and see what happens.</p>\n<h2>Step 2: Physical Setup</h2>\n<p>This part was actually straightforward:</p>\n<ol>\n<li>Unbox the Asus (finally)</li>\n<li>Connect ethernet cable from Fritzbox LAN port to Asus WAN port (the blue one)</li>\n<li>Power everything up</li>\n<li>Download the Asus Router app</li>\n</ol>\n<p>The app walked me through initial setup, asking me to choose between creating a new network or extending an existing one. I chose \"new network\" and selected DHCP as my WAN type, accepting the other defaults.</p>\n<p>Then came the fun part: picking a WiFi name. After cycling through metal puns, Stephen King references, and various nerdy jokes, I landed on something that made me laugh. That part took longer than the actual setup.</p>\n<h2>Step 3: Everything Seemed Fine (It Wasn't)</h2>\n<p>The Asus app said setup was complete. I could see my new WiFi network broadcasting. The app showed everything as connected and happy.</p>\n<p>But when I tried to actually use the internet... nothing. No connection.</p>\n<h2>Step 4: Down the Troubleshooting Rabbit Hole</h2>\n<p><strong>Problem #1: WAN IP showing 0.0.0.0</strong></p>\n<p>The Asus router settings showed my WAN IP as <code>0.0.0.0</code>, which apparently means \"I'm not getting an IP address from the Fritzbox.\"</p>\n<p>First attempts to fix:</p>\n<ul>\n<li>Rebooted the Asus (didn't work)</li>\n<li>Rebooted the Fritzbox (didn't work)</li>\n<li>Checked the physical connections (all good)</li>\n<li>Questioned my life choices (very effective, but didn't fix the internet)</li>\n</ul>\n<p><strong>Problem #2: The DHCP Mystery</strong></p>\n<p>I logged into my Fritzbox (at <code>fritz.box</code> or <code>192.168.178.1</code>) and found something interesting. The Fritzbox could see a device connected to port 4 - listed as \"PC-86-FB-ED-94-40-49\" (definitely my Asus router based on the MAC address). But it wasn't assigning it an IP.</p>\n<p>The DHCP server was enabled, with plenty of available addresses in the range. So why wasn't it working?</p>\n<p>At this point I was stuck. The Asus should be getting an IP via DHCP automatically, but it just wasn't happening. I checked the Fritzbox settings - everything looked fine. No obvious DHCP conflicts, no MAC filtering blocking the Asus.</p>\n<p>I thought: if DHCP isn't working, just assign a static IP manually. Standard troubleshooting step.</p>\n<p>I picked 192.168.178.50 - well above the Fritzbox's default DHCP range (.20–.199) to avoid conflicts.</p>\n<p><strong>The Fix: Static IP Configuration</strong></p>\n<p>In the Asus router settings, I changed from DHCP to Static IP and manually entered:</p>\n<ul>\n<li>IP Address: <code>192.168.178.50</code></li>\n<li>Subnet Mask: <code>255.255.255.0</code></li>\n<li>Default Gateway: <code>192.168.178.1</code></li>\n<li>DNS Server: <code>192.168.178.1</code></li>\n</ul>\n<p>This bypassed the DHCP issue entirely. The Asus app suddenly showed a valid WAN connection!</p>\n<p>Progress! Except...</p>\n<p><strong>Problem #3: Router Has Internet, Devices Don't</strong></p>\n<p>My phone connected to the new WiFi just fine. It got an IP address in the <code>192.168.50.x</code> range. I could access the Asus router settings at its default address, <code>192.168.50.1</code>. Everything looked perfect.</p>\n<p>But still no actual internet.</p>\n<p><strong>The Diagnostic That Revealed Everything</strong></p>\n<p>I tried accessing the Fritzbox (<code>192.168.178.1</code>) from my phone while connected to the Asus WiFi. It failed completely.</p>\n<p>The Asus couldn't talk to the Fritzbox. Even though they were physically connected. Even though the Asus showed a valid WAN connection. The routing between them was completely broken.</p>\n<p>I verified all the WAN settings were correct. I tried pinging from the router's diagnostic tools. Nothing worked.</p>\n<p>Then I tried something stupid simple.</p>\n<p><strong>The Actual Fix: Wrong Port</strong></p>\n<p>I unplugged the ethernet cable from LAN port 4 on the Fritzbox and plugged it into port 3 instead.</p>\n<p>It immediately worked.</p>\n<p>Port 4 apparently had some restriction or configuration I never figured out. I checked Fritzbox logs, firewall rules, VLAN settings, port-specific configs - nothing obvious. Port 3 worked, so I moved on.</p>\n<h2>What I Learned</h2>\n<p><strong>1. Start Simple</strong>\nWhen something doesn't work, start with the physical layer. Different port? Different cable? Sometimes the answer is that basic.</p>\n<p><strong>2. Work Layer by Layer</strong></p>\n<ul>\n<li>Can the router see the modem? (Yes - device showed up in Fritzbox)</li>\n<li>Can the router get an IP? (No - fixed with static IP)</li>\n<li>Can the router reach the internet? (No - wrong port)</li>\n<li>Can devices reach the router? (Yes)</li>\n<li>Can devices reach the internet through the router? (Finally, yes)</li>\n</ul>\n<p><strong>3. Double NAT Isn't Scary</strong>\nFor 95% of home use, double NAT works perfectly fine. The internet loves to make it sound like a critical issue, but for browsing, streaming, and normal life, it's completely transparent.</p>\n<p><strong>4. Learning in Public Means Sharing the Mess</strong>\nI didn't know what I was doing. I made wrong assumptions. I tried things that didn't work. And that's the point - this is what real learning looks like. Not polished tutorials where everything works perfectly the first time.</p>\n<h2>The Current Setup</h2>\n<p>I now have:</p>\n<ul>\n<li>Fritzbox handling the ISP connection</li>\n<li>Asus RT-BE88U connected via LAN port 3 (not 4, very important)</li>\n<li>Static IP configuration on the WAN side</li>\n<li>Separate 2.4GHz, 5GHz, and 6GHz WiFi networks</li>\n<li>Coverage throughout my entire house</li>\n<li>No more dead zones</li>\n</ul>\n<p>Is it optimal? Probably not. Could I further optimize by putting the Fritzbox in bridge mode? Sure. But it works, it's fast, and I'm not touching it again until something breaks.</p>\n<h2>So... What Actually Worked?</h2>\n<p>I went with static IP configuration in the end. DHCP just refused to cooperate on the Fritzbox (port 4 was the actual problem, but I didn't know that yet), and once I figured out how to set the Asus manually, everything connected immediately.</p>\n<p>Double NAT? I ended up leaving it alone. Everything I need - browsing, streaming, regular stuff - works fine. If I ever need port forwarding for something, I'll deal with it then.</p>\n<p>The whole thing took way longer than it should have, mostly because I was plugged into the wrong LAN port. But I got there.</p>\n<hr>\n<p><em>Having router troubles? Different experience? Let me know - I'm always learning.</em></p>",
            "url": "https://lukemanning.ie/blog/asus-router-setup-journey",
            "title": "Setting Up an Asus Router Behind an ISP Modem: My Unexpected Gotchas",
            "summary": "<p>I bought an Asus RT-BE88U router because my ISP-provided Fritzbox WiFi was, to put it mildly, absolute garbage. What I thought would be a simple \"plug it in and go\" situation turned into a two-hour troubleshooting adventure that taught me more about networking than I ever wanted to know.</p>\n<p>This is part of a pattern I notice in my work: <a href=\"/blog/unraid-docker-label-fix\">home server maintenance, recovery tasks, and infrastructure projects</a> don't always go smoothly, but they're worth documenting.</p>\n<p>This is that story. No pretending I knew what I was doing. Just the actual messy process of figuring it out.</p>\n<h2>The Starting Point</h2>\n<p><strong>What I had:</strong></p>\n<ul>\n<li>Brand new Asus RT-BE88U still in the box</li>\n<li>Fritzbox modem/router combo from Digiweb (my ISP)</li>\n<li>Shockingly bad WiFi coverage</li>\n<li>Confidence that this would be easy</li>\n</ul>\n<p><strong>What I knew about networking:</strong></p>\n<ul>\n<li>Enterprise troubleshooting, VLANs, routing protocols, complex network issues</li>\n<li>Consumer router setups behind ISP modems? Not my area</li>\n</ul>\n<h2>Step 1: Understanding Double NAT (Or Not Understanding It)</h2>\n<p>The first thing I learned is that my Fritzbox isn't just a modem - it's a router too. Which means when I add the Asus, I'm creating what's called \"double NAT.\"</p>\n<p>When someone first explained this to me, I got a whole technical breakdown about Network Address Translation and routing tables and... honestly, this is all enterprise networking stuff I deal with daily. But I needed it in consumer terms.</p>\n<p>As I understand it now: both routers are translating network addresses. The Fritzbox translates from your ISP's public IP to its internal network (192.168.178.x), then the Asus translates again to its own network (192.168.50.x). Packets go through two address translations before reaching the internet.</p>\n<p>At this point, I was worried this was going to cause problems. Everything I read said \"double NAT is bad\" but nobody could really explain why in a way I understood. Gaming? Servers? I don't do any of that.</p>\n<p>I decided to just try it and see what happens.</p>\n<h2>Step 2: Physical Setup</h2>\n<p>This part was actually straightforward:</p>\n<ol>\n<li>Unbox the Asus (finally)</li>\n<li>Connect ethernet cable from Fritzbox LAN port to Asus WAN port (the blue one)</li>\n<li>Power everything up</li>\n<li>Download the Asus Router app</li>\n</ol>\n<p>The app walked me through initial setup, asking me to choose between creating a new network or extending an existing one. I chose \"new network\" and selected DHCP as my WAN type, accepting the other defaults.</p>\n<p>Then came the fun part: picking a WiFi name. After cycling through metal puns, Stephen King references, and various nerdy jokes, I landed on something that made me laugh. That part took longer than the actual setup.</p>\n<h2>Step 3: Everything Seemed Fine (It Wasn't)</h2>\n<p>The Asus app said setup was complete. I could see my new WiFi network broadcasting. The app showed everything as connected and happy.</p>\n<p>But when I tried to actually use the internet... nothing. No connection.</p>\n<h2>Step 4: Down the Troubleshooting Rabbit Hole</h2>\n<p><strong>Problem #1: WAN IP showing 0.0.0.0</strong></p>\n<p>The Asus router settings showed my WAN IP as <code>0.0.0.0</code>, which apparently means \"I'm not getting an IP address from the Fritzbox.\"</p>\n<p>First attempts to fix:</p>\n<ul>\n<li>Rebooted the Asus (didn't work)</li>\n<li>Rebooted the Fritzbox (didn't work)</li>\n<li>Checked the physical connections (all good)</li>\n<li>Questioned my life choices (very effective, but didn't fix the internet)</li>\n</ul>\n<p><strong>Problem #2: The DHCP Mystery</strong></p>\n<p>I logged into my Fritzbox (at <code>fritz.box</code> or <code>192.168.178.1</code>) and found something interesting. The Fritzbox could see a device connected to port 4 - listed as \"PC-86-FB-ED-94-40-49\" (definitely my Asus router based on the MAC address). But it wasn't assigning it an IP.</p>\n<p>The DHCP server was enabled, with plenty of available addresses in the range. So why wasn't it working?</p>\n<p>At this point I was stuck. The Asus should be getting an IP via DHCP automatically, but it just wasn't happening. I checked the Fritzbox settings - everything looked fine. No obvious DHCP conflicts, no MAC filtering blocking the Asus.</p>\n<p>I thought: if DHCP isn't working, just assign a static IP manually. Standard troubleshooting step.</p>\n<p>I picked 192.168.178.50 - well above the Fritzbox's default DHCP range (.20–.199) to avoid conflicts.</p>\n<p><strong>The Fix: Static IP Configuration</strong></p>\n<p>In the Asus router settings, I changed from DHCP to Static IP and manually entered:</p>\n<ul>\n<li>IP Address: <code>192.168.178.50</code></li>\n<li>Subnet Mask: <code>255.255.255.0</code></li>\n<li>Default Gateway: <code>192.168.178.1</code></li>\n<li>DNS Server: <code>192.168.178.1</code></li>\n</ul>\n<p>This bypassed the DHCP issue entirely. The Asus app suddenly showed a valid WAN connection!</p>\n<p>Progress! Except...</p>\n<p><strong>Problem #3: Router Has Internet, Devices Don't</strong></p>\n<p>My phone connected to the new WiFi just fine. It got an IP address in the <code>192.168.50.x</code> range. I could access the Asus router settings at its default address, <code>192.168.50.1</code>. Everything looked perfect.</p>\n<p>But still no actual internet.</p>\n<p><strong>The Diagnostic That Revealed Everything</strong></p>\n<p>I tried accessing the Fritzbox (<code>192.168.178.1</code>) from my phone while connected to the Asus WiFi. It failed completely.</p>\n<p>The Asus couldn't talk to the Fritzbox. Even though they were physically connected. Even though the Asus showed a valid WAN connection. The routing between them was completely broken.</p>\n<p>I verified all the WAN settings were correct. I tried pinging from the router's diagnostic tools. Nothing worked.</p>\n<p>Then I tried something stupid simple.</p>\n<p><strong>The Actual Fix: Wrong Port</strong></p>\n<p>I unplugged the ethernet cable from LAN port 4 on the Fritzbox and plugged it into port 3 instead.</p>\n<p>It immediately worked.</p>\n<p>Port 4 apparently had some restriction or configuration I never figured out. I checked Fritzbox logs, firewall rules, VLAN settings, port-specific configs - nothing obvious. Port 3 worked, so I moved on.</p>\n<h2>What I Learned</h2>\n<p><strong>1. Start Simple</strong>\nWhen something doesn't work, start with the physical layer. Different port? Different cable? Sometimes the answer is that basic.</p>\n<p><strong>2. Work Layer by Layer</strong></p>\n<ul>\n<li>Can the router see the modem? (Yes - device showed up in Fritzbox)</li>\n<li>Can the router get an IP? (No - fixed with static IP)</li>\n<li>Can the router reach the internet? (No - wrong port)</li>\n<li>Can devices reach the router? (Yes)</li>\n<li>Can devices reach the internet through the router? (Finally, yes)</li>\n</ul>\n<p><strong>3. Double NAT Isn't Scary</strong>\nFor 95% of home use, double NAT works perfectly fine. The internet loves to make it sound like a critical issue, but for browsing, streaming, and normal life, it's completely transparent.</p>\n<p><strong>4. Learning in Public Means Sharing the Mess</strong>\nI didn't know what I was doing. I made wrong assumptions. I tried things that didn't work. And that's the point - this is what real learning looks like. Not polished tutorials where everything works perfectly the first time.</p>\n<h2>The Current Setup</h2>\n<p>I now have:</p>\n<ul>\n<li>Fritzbox handling the ISP connection</li>\n<li>Asus RT-BE88U connected via LAN port 3 (not 4, very important)</li>\n<li>Static IP configuration on the WAN side</li>\n<li>Separate 2.4GHz, 5GHz, and 6GHz WiFi networks</li>\n<li>Coverage throughout my entire house</li>\n<li>No more dead zones</li>\n</ul>\n<p>Is it optimal? Probably not. Could I further optimize by putting the Fritzbox in bridge mode? Sure. But it works, it's fast, and I'm not touching it again until something breaks.</p>\n<h2>So... What Actually Worked?</h2>\n<p>I went with static IP configuration in the end. DHCP just refused to cooperate on the Fritzbox (port 4 was the actual problem, but I didn't know that yet), and once I figured out how to set the Asus manually, everything connected immediately.</p>\n<p>Double NAT? I ended up leaving it alone. Everything I need - browsing, streaming, regular stuff - works fine. If I ever need port forwarding for something, I'll deal with it then.</p>\n<p>The whole thing took way longer than it should have, mostly because I was plugged into the wrong LAN port. But I got there.</p>\n<hr>\n<p><em>Having router troubles? Different experience? Let me know - I'm always learning.</em></p>",
            "date_modified": "2025-11-27T00:00:00.000Z",
            "tags": [
                "homelab"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/unraid-docker-label-fix",
            "content_html": "<p>So I just upgraded my Unraid server from a very old 6.9 installation to 7.0.1, and immediately ran into a fun little problem: all my Docker containers were suddenly marked as \"3rd party.\" Couldn't edit them, couldn't check for updates, couldn't do anything except stare at them in frustration.</p>\n<h2>The Problem</h2>\n<p>There's a similar thread documented here in the <a href=\"https://forums.unraid.net/topic/178736-docker-container-now-shows-3rd-party/\">Unraid Forums</a> which I found helpful.</p>\n<p>Turns out, Dockerman in Unraid 7.0+ (Unraid's Docker management plugin) uses a container label <code>net.unraid.docker.managed=dockerman</code> to determine which containers it actually manages. My containers were created way back on an older version of Unraid, so they didn't have this label. Without it, Dockerman basically said \"not my problem\" and refused to touch them.</p>\n<p>The nuclear option would be to recreate every single container from scratch, but that's tedious and error-prone when you have dozens of containers with specific configurations. I know I could reuse an existing template as well.. but it still felt like a tedious task. So I did what I normally do and implemented an overly engineered solution to a problem that I could fixed pretty quickly doing it manually.</p>\n<h2>The Solution</h2>\n<p>The good news is you can add the missing label using Docker's CLI without manually reconfiguring everything.</p>\n<p>I started by testing this on one container first - Jackett, which is one of my torrent index containers. I wanted to make sure the whole process worked before batch-processing everything.</p>\n<p>First, I generated the docker run command using <code>runlike</code> (it inspects a running container and outputs the equivalent <code>docker run</code> command):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">docker</span><span style=\"color:#9ECBFF\"> run</span><span style=\"color:#79B8FF\"> --rm</span><span style=\"color:#79B8FF\"> -v</span><span style=\"color:#9ECBFF\"> /var/run/docker.sock:/var/run/docker.sock</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    assaflavie/runlike</span><span style=\"color:#9ECBFF\"> Jackett</span><span style=\"color:#F97583\"> ></span><span style=\"color:#9ECBFF\"> /tmp/jackett_run.sh</span></span></code></pre>\n<p>(Note: you'll need root privileges to run docker commands in the Unraid terminal - otherwise you'll get \"permission denied\" errors.)</p>\n<p>Then I reviewed what runlike generated:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">cat</span><span style=\"color:#9ECBFF\"> /tmp/jackett_run.sh</span></span></code></pre>\n<p>This showed me the full docker run command with all the volumes, ports, environment variables - exact configuration for my Jackett container.</p>\n<p>Next, I needed to add the missing label. I opened the file with nano:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">nano</span><span style=\"color:#9ECBFF\"> /tmp/jackett_run.sh</span></span></code></pre>\n<p>And added <code>--label net.unraid.docker.managed=dockerman</code> and <code>--detach=true</code> right after <code>docker run</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">docker</span><span style=\"color:#9ECBFF\"> run</span><span style=\"color:#79B8FF\"> --label</span><span style=\"color:#9ECBFF\"> net.unraid.docker.managed=dockerman</span><span style=\"color:#79B8FF\"> --detach=true</span><span style=\"color:#79B8FF\"> --name=Jackett</span><span style=\"color:#9ECBFF\"> ...</span></span></code></pre>\n<p>Then I stopped the old container, removed it, and recreated it with the new configuration:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">docker</span><span style=\"color:#9ECBFF\"> stop</span><span style=\"color:#9ECBFF\"> Jackett</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">docker</span><span style=\"color:#9ECBFF\"> rm</span><span style=\"color:#9ECBFF\"> Jackett</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">bash</span><span style=\"color:#9ECBFF\"> /tmp/jackett_run.sh</span></span></code></pre>\n<p>Finally, I needed to make Unraid fully recognize the container. In the Docker tab, I clicked on Jackett to open its dropdown menu and selected \"Force Update.\" This tells Unraid to add its other management labels.</p>\n<p>After doing this, Jackett showed up properly in Unraid instead of as \"3rd party.\" Success!</p>\n<h2>The Automated Script</h2>\n<p>Once I verified the manual process worked, I created a script to batch-process all my containers. The script loops through all running containers, generates the run command, injects the label, recreates the container, and queues a Force Update:</p>\n<p><a href=\"https://gist.github.com/ManningWorks/0f57db9c450d7b7dea741e585b31d23e\">https://gist.github.com/ManningWorks/0f57db9c450d7b7dea741e585b31d23e</a></p>\n<p>To use this, I added it to Unraid's User Scripts plugin (you can install it from Community Applications). This lets you run one-off scripts safely instead of running them directly in the Bash Shell.</p>\n<p>If you're comfortable with scripts, this will process all your containers at once. Otherwise, the manual steps above work fine for one-off fixes.</p>\n<h2>Important Notes</h2>\n<p>By the way, if you run into different data recovery issues — like <a href=\"/blog/git-corrupt-object-recovery\">Git object corruption</a> — the same instinct applies: move don't delete, have a rollback plan.</p>\n<ul>\n<li><strong>Your data is safe</strong> - This process only removes and recreates the container definitions, not your actual data in appdata or volumes</li>\n<li><strong>Test on one container first</strong> - Make sure the manual steps work for your setup before running the bash script</li>\n<li><strong>The Force Update step matters</strong> - Don't skip it, as it adds the additional Unraid management labels</li>\n<li><strong>Back up your flash drive</strong> - Before any major changes, it's always good practice to back up your Unraid configuration</li>\n</ul>\n<h2>Why This Happens</h2>\n<p>Unraid made this change to better track which containers it's managing versus containers you might have created manually or through other tools. It's actually a good change for container management, but it does mean old containers need this label added retroactively.</p>\n<p>Hopefully this saves someone else the frustration of staring at dozens of \"3rd party\" containers after an upgrade! If this saved you an hour, the Gist is there to share.</p>",
            "url": "https://lukemanning.ie/blog/unraid-docker-label-fix",
            "title": "Fixing Unraid Docker Containers After Upgrading - The Missing Label Problem",
            "summary": "<p>So I just upgraded my Unraid server from a very old 6.9 installation to 7.0.1, and immediately ran into a fun little problem: all my Docker containers were suddenly marked as \"3rd party.\" Couldn't edit them, couldn't check for updates, couldn't do anything except stare at them in frustration.</p>\n<h2>The Problem</h2>\n<p>There's a similar thread documented here in the <a href=\"https://forums.unraid.net/topic/178736-docker-container-now-shows-3rd-party/\">Unraid Forums</a> which I found helpful.</p>\n<p>Turns out, Dockerman in Unraid 7.0+ (Unraid's Docker management plugin) uses a container label <code>net.unraid.docker.managed=dockerman</code> to determine which containers it actually manages. My containers were created way back on an older version of Unraid, so they didn't have this label. Without it, Dockerman basically said \"not my problem\" and refused to touch them.</p>\n<p>The nuclear option would be to recreate every single container from scratch, but that's tedious and error-prone when you have dozens of containers with specific configurations. I know I could reuse an existing template as well.. but it still felt like a tedious task. So I did what I normally do and implemented an overly engineered solution to a problem that I could fixed pretty quickly doing it manually.</p>\n<h2>The Solution</h2>\n<p>The good news is you can add the missing label using Docker's CLI without manually reconfiguring everything.</p>\n<p>I started by testing this on one container first - Jackett, which is one of my torrent index containers. I wanted to make sure the whole process worked before batch-processing everything.</p>\n<p>First, I generated the docker run command using <code>runlike</code> (it inspects a running container and outputs the equivalent <code>docker run</code> command):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">docker</span><span style=\"color:#9ECBFF\"> run</span><span style=\"color:#79B8FF\"> --rm</span><span style=\"color:#79B8FF\"> -v</span><span style=\"color:#9ECBFF\"> /var/run/docker.sock:/var/run/docker.sock</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    assaflavie/runlike</span><span style=\"color:#9ECBFF\"> Jackett</span><span style=\"color:#F97583\"> ></span><span style=\"color:#9ECBFF\"> /tmp/jackett_run.sh</span></span></code></pre>\n<p>(Note: you'll need root privileges to run docker commands in the Unraid terminal - otherwise you'll get \"permission denied\" errors.)</p>\n<p>Then I reviewed what runlike generated:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">cat</span><span style=\"color:#9ECBFF\"> /tmp/jackett_run.sh</span></span></code></pre>\n<p>This showed me the full docker run command with all the volumes, ports, environment variables - exact configuration for my Jackett container.</p>\n<p>Next, I needed to add the missing label. I opened the file with nano:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">nano</span><span style=\"color:#9ECBFF\"> /tmp/jackett_run.sh</span></span></code></pre>\n<p>And added <code>--label net.unraid.docker.managed=dockerman</code> and <code>--detach=true</code> right after <code>docker run</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">docker</span><span style=\"color:#9ECBFF\"> run</span><span style=\"color:#79B8FF\"> --label</span><span style=\"color:#9ECBFF\"> net.unraid.docker.managed=dockerman</span><span style=\"color:#79B8FF\"> --detach=true</span><span style=\"color:#79B8FF\"> --name=Jackett</span><span style=\"color:#9ECBFF\"> ...</span></span></code></pre>\n<p>Then I stopped the old container, removed it, and recreated it with the new configuration:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">docker</span><span style=\"color:#9ECBFF\"> stop</span><span style=\"color:#9ECBFF\"> Jackett</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">docker</span><span style=\"color:#9ECBFF\"> rm</span><span style=\"color:#9ECBFF\"> Jackett</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">bash</span><span style=\"color:#9ECBFF\"> /tmp/jackett_run.sh</span></span></code></pre>\n<p>Finally, I needed to make Unraid fully recognize the container. In the Docker tab, I clicked on Jackett to open its dropdown menu and selected \"Force Update.\" This tells Unraid to add its other management labels.</p>\n<p>After doing this, Jackett showed up properly in Unraid instead of as \"3rd party.\" Success!</p>\n<h2>The Automated Script</h2>\n<p>Once I verified the manual process worked, I created a script to batch-process all my containers. The script loops through all running containers, generates the run command, injects the label, recreates the container, and queues a Force Update:</p>\n<p><a href=\"https://gist.github.com/ManningWorks/0f57db9c450d7b7dea741e585b31d23e\">https://gist.github.com/ManningWorks/0f57db9c450d7b7dea741e585b31d23e</a></p>\n<p>To use this, I added it to Unraid's User Scripts plugin (you can install it from Community Applications). This lets you run one-off scripts safely instead of running them directly in the Bash Shell.</p>\n<p>If you're comfortable with scripts, this will process all your containers at once. Otherwise, the manual steps above work fine for one-off fixes.</p>\n<h2>Important Notes</h2>\n<p>By the way, if you run into different data recovery issues — like <a href=\"/blog/git-corrupt-object-recovery\">Git object corruption</a> — the same instinct applies: move don't delete, have a rollback plan.</p>\n<ul>\n<li><strong>Your data is safe</strong> - This process only removes and recreates the container definitions, not your actual data in appdata or volumes</li>\n<li><strong>Test on one container first</strong> - Make sure the manual steps work for your setup before running the bash script</li>\n<li><strong>The Force Update step matters</strong> - Don't skip it, as it adds the additional Unraid management labels</li>\n<li><strong>Back up your flash drive</strong> - Before any major changes, it's always good practice to back up your Unraid configuration</li>\n</ul>\n<h2>Why This Happens</h2>\n<p>Unraid made this change to better track which containers it's managing versus containers you might have created manually or through other tools. It's actually a good change for container management, but it does mean old containers need this label added retroactively.</p>\n<p>Hopefully this saves someone else the frustration of staring at dozens of \"3rd party\" containers after an upgrade! If this saved you an hour, the Gist is there to share.</p>",
            "date_modified": "2025-11-26T00:00:00.000Z",
            "tags": [
                "homelab",
                "docker"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/building-my-career-timeline",
            "content_html": "<p>I just added a career timeline to my About page. Not because it's a resume requirement, but because the process of building it taught me something unexpected about both code and career.</p>\n<h2>Why I Even Started This</h2>\n<p>I've spent years helping people solve complex technical problems. My job is to take something complicated and make it make sense. But I realized I'd never applied that same principle to my own career story.</p>\n<p>Looking back at 10+ years in tech support and technical leadership, it's easy to just see it as \"a bunch of jobs.\" But when you stop and actually write down what you learned at each stage, you might notice some patterns emerge.</p>\n<p>So I decided to build a timeline component for my About page. Not just to list where I worked but to capture the real lessons from each role and the things I wish I'd known at the time. This was part of a broader effort to make my About page less of a text dump — I also built a responsive photo card around the same time (the post never made it past draft).</p>\n<h3>First Attempt: The Inline Approach</h3>\n<p>I started by just writing the timeline directly in my About page component. Bad move. I had this massive chunk of JSX mixed with my page layout:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// This got messy FAST</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"space-y-8\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"border-l-4 border-quantum-violet pl-6\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"flex items-center gap-2 mb-2\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">span</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"bg-quantum-violet text-white px-3 py-1 rounded-full text-sm\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        2014-2016</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;/</span><span style=\"color:#85E89D\">span</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">h3</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"font-semibold\"</span><span style=\"color:#E1E4E8\">>Technical Support Engineer&#x3C;/</span><span style=\"color:#85E89D\">h3</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">span</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-neural-silver\"</span><span style=\"color:#E1E4E8\">>@ Dell EMC&#x3C;/</span><span style=\"color:#85E89D\">span</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;!-- ... way more content ... --></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;!-- ... repeat for every job ... --></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>After a while of copying and pasting, I realized this was going to be a nightmare to maintain. Every change would require digging through this nested structure, and the About page component was already getting long.</p>\n<h3>Second Attempt: Extract to Component</h3>\n<p>Okay, let's make this a proper React component. I created <code>src/components/Journey.tsx</code> and moved the timeline logic there:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#F97583\"> function</span><span style=\"color:#B392F0\"> Journey</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">section</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"space-y-8\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">h2</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-2xl font-bold text-quantum-violet\"</span><span style=\"color:#E1E4E8\">>My Journey&#x3C;/</span><span style=\"color:#85E89D\">h2</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"space-y-8\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        {</span><span style=\"color:#6A737D\">/* Timeline entries here */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">section</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// In src/app/about/page.tsx</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> Journey </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@/components/Journey'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#F97583\"> function</span><span style=\"color:#B392F0\"> AboutPage</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">main</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"max-w-4xl mx-auto px-6 py-12\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#79B8FF\">Journey</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">main</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  )</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Better, but now I had another problem. How do I pass job data? I could hardcode it.. but that felt wrong. I spent some time debating whether to create a separate data file, use props, or just keep it simple.</p>\n<p>I ended up hardcoding it first because I wanted to see if the component would even work.</p>\n<h3>The Grid Layout Problem</h3>\n<p>The timeline needed to be responsive. On mobile, everything should stack vertically. On desktop, I wanted the date and title on the left, content on the right.</p>\n<p>My first attempt with Tailwind's grid system:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"grid grid-cols-1 lg:grid-cols-12 gap-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:col-span-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span><span style=\"color:#6A737D\">/* Date and title */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:col-span-8\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span><span style=\"color:#6A737D\">/* Content */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>This worked, but the alignment was weird. The date badge was floating, and the content didn't line up properly. I spent too long trying <code>flex-grow</code>, <code>gap-4</code>, <code>mt-4</code>, switching between <code>items-start</code> and <code>items-center</code> before realizing I needed to structure it differently.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// Second attempt - tried flexbox</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"flex flex-col lg:flex-row gap-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:w-1/3\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span><span style=\"color:#6A737D\">/* Date and title */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:w-2/3\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span><span style=\"color:#6A737D\">/* Content */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>This got the horizontal layout right but broke the vertical timeline effect. The border-left I was using for the timeline line wouldn't span the full height of the entry anymore - it got chopped off at the first child element.</p>\n<p>The alignment was weird because the date badge sat awkwardly in the left column, disconnected from the timeline border, and there was no way to make it overlap properly without complex positioning.</p>\n<p>What finally worked:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// Full entry structure</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"border-l-4 border-quantum-violet pl-6 relative\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"absolute -left-3 top-0\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">span</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"bg-quantum-violet text-white px-3 py-1 rounded-full text-sm\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      2014-2016</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">span</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  </span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"grid grid-cols-1 lg:grid-cols-12 gap-4 mt-2\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:col-span-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">h3</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"font-semibold\"</span><span style=\"color:#E1E4E8\">>Technical Support Engineer&#x3C;/</span><span style=\"color:#85E89D\">h3</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">span</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-neural-silver\"</span><span style=\"color:#E1E4E8\">>@ Dell EMC&#x3C;/</span><span style=\"color:#85E89D\">span</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:col-span-8\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">p</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-code-carbon mb-3\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        Learned systematic troubleshooting under pressure through the GSAP program.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;/</span><span style=\"color:#85E89D\">p</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"border-l-2 border-automation-amber pl-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;</span><span style=\"color:#85E89D\">p</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-sm text-automation-amber font-medium\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          Key Learning: Part of career growth is knowing when you're ready for the next challenge.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;/</span><span style=\"color:#85E89D\">p</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>The <code>absolute -left-3</code> positions the badge 0.75rem (3 * 0.25rem) to the left of the border. Since the border is <code>border-l-4</code> (1rem), this pulls the badge halfway across the border, creating that centered overlap effect I was going for.</p>\n<p>The key insight was using the border-left as the timeline line and positioning the date badge to overlap it. Obvious in hindsight, but it took me a couple of attempts to get there.</p>\n<h2>What I'd Do Differently (Looking Back)</h2>\n<p>If I were starting this component again, knowing what I know now:</p>\n<ul>\n<li>Start with a clear data structure. Don't overthink it but have a mental model of what job entries look like before writing JSX</li>\n<li>Use CSS custom properties more consistently instead of bouncing between semantic tokens and hardcoded values</li>\n<li>Build a proper prop interface from the beginning instead of refactoring later when I realized I needed more flexibility</li>\n</ul>\n<p>That said, the messy approach worked. It let me iterate quickly and focus on the content rather than getting stuck in architecture. A previous manager of mine once said \"Don't let perfect block the road to progress\" and that still resonates with me today. That was the approach I took here.</p>\n<p>The component finally worked. The layout was responsive, the date badges aligned, everything rendered correctly. I could have stopped there.</p>\n<h2>The Harder Part: Writing It</h2>\n<p>Building the component took a few hours including all the debugging. Writing the actual content took significantly more reflection.</p>\n<p>Looking back at each role, patterns I hadn't noticed before started to emerge.</p>\n<h3>Dell EMC (2014-2016)</h3>\n<p>This was my first proper job, and the GSAP program laid the essential foundation for my entire career. I learned systematic troubleshooting, how to communicate clearly when things are broken and someone's waiting on you. But the role also helped me understand my own value and my drive to take on more. Looking back, I was frustrated that there wasn't more room to grow. That's when I realized: I needed to find a culture that would let me meet that ambition head-on. Past-me was ready for the next challenge—I just hadn't figured out how to ask for it yet.</p>\n<h3>Nuix (2016-2020)</h3>\n<p>This job completely reshaped my definition of what technical support can be. I became a dedicated support resource for one of our largest customers in EMEA, working on-site with them, running demos for the sales team, and diving into the Engine APIs to write my own scripts. Great. But then I hit a wall—my passion and responsibilities had expanded, but the role hadn't. I spent way too long thinking I should just be grateful before realizing: no, I needed to find a place that would recognize that contribution. The irony was not lost on me that I'd outgrown the role that had taught me so much.</p>\n<h3>Cohesity - TSE (2020-2021)</h3>\n<p>I joined Cohesity in March 2020. Two weeks later, the world shut down. Starting a new job remotely, on a new product, after years of being the go-to person at Nuix—the imposter syndrome was real. I spent that year proving to myself that I could do it again. Learning a complex product from scratch, building new relationships through a screen, finding my footing in a company I'd never set foot in. Here's what I learned the hard way: your reputation doesn't transfer. Your ability to build one does.</p>\n<h3>Cohesity - Senior TSE (2021-2022)</h3>\n<p>The imposter syndrome from joining during Covid hadn't fully faded, but something had shifted. Management started routing the harder cases my way—complex database and backup issues that needed more than a standard playbook. I also knew early on that I wanted Tech Lead, so I stopped waiting for the title and started doing the job. Mentoring new hires, sharing what I knew, making myself useful beyond my own ticket queue. Honestly, I was surprised how quickly I stopped caring about the title once I started the actual work.</p>\n<h3>Cohesity - Technical Lead (2022-Present)</h3>\n<p>My title is Technical Lead, which in practice means I'm the person the team comes to when something doesn't make sense. As much as I love getting my hands on a weird, messy, complex problem, the real win for me isn't fixing it. It's turning around and showing my team how they can solve it too. I've also started noticing patterns beyond individual cases. Gaps in how we worked, how we shared knowledge. One change started as a tool I built without asking permission, shared with the team, and watched quietly become the new standard. I'm still facepalming that this worked so well—sometimes the best way to drive change is to build the thing first and ask for forgiveness later.</p>\n<h2>What I'm Taking From This</h2>\n<p>Writing this timeline forced me to articulate lessons I'd internalized but never expressed. I thought I knew what I learned at each job, but putting it on paper revealed patterns I hadn't seen.</p>\n<p>My path definitely wasn't a straight shot up. Lateral moves, expanded roles, finding opportunities to take on more - that was my actual growth. Growth is rarely linear.</p>\n<p>The technical skills matter (I can build the component), but understanding WHY I'm building it makes it meaningful. That's the part that sticks with you.</p>\n<p>I started writing this thinking I was just documenting where I'd been. I ended up learning something about where I'm going.</p>",
            "url": "https://lukemanning.ie/blog/building-my-career-timeline",
            "title": "Building my career timeline",
            "summary": "<p>I just added a career timeline to my About page. Not because it's a resume requirement, but because the process of building it taught me something unexpected about both code and career.</p>\n<h2>Why I Even Started This</h2>\n<p>I've spent years helping people solve complex technical problems. My job is to take something complicated and make it make sense. But I realized I'd never applied that same principle to my own career story.</p>\n<p>Looking back at 10+ years in tech support and technical leadership, it's easy to just see it as \"a bunch of jobs.\" But when you stop and actually write down what you learned at each stage, you might notice some patterns emerge.</p>\n<p>So I decided to build a timeline component for my About page. Not just to list where I worked but to capture the real lessons from each role and the things I wish I'd known at the time. This was part of a broader effort to make my About page less of a text dump — I also built a responsive photo card around the same time (the post never made it past draft).</p>\n<h3>First Attempt: The Inline Approach</h3>\n<p>I started by just writing the timeline directly in my About page component. Bad move. I had this massive chunk of JSX mixed with my page layout:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// This got messy FAST</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"space-y-8\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"border-l-4 border-quantum-violet pl-6\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"flex items-center gap-2 mb-2\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">span</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"bg-quantum-violet text-white px-3 py-1 rounded-full text-sm\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        2014-2016</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;/</span><span style=\"color:#85E89D\">span</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">h3</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"font-semibold\"</span><span style=\"color:#E1E4E8\">>Technical Support Engineer&#x3C;/</span><span style=\"color:#85E89D\">h3</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">span</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-neural-silver\"</span><span style=\"color:#E1E4E8\">>@ Dell EMC&#x3C;/</span><span style=\"color:#85E89D\">span</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;!-- ... way more content ... --></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;!-- ... repeat for every job ... --></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>After a while of copying and pasting, I realized this was going to be a nightmare to maintain. Every change would require digging through this nested structure, and the About page component was already getting long.</p>\n<h3>Second Attempt: Extract to Component</h3>\n<p>Okay, let's make this a proper React component. I created <code>src/components/Journey.tsx</code> and moved the timeline logic there:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#F97583\"> function</span><span style=\"color:#B392F0\"> Journey</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">section</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"space-y-8\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">h2</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-2xl font-bold text-quantum-violet\"</span><span style=\"color:#E1E4E8\">>My Journey&#x3C;/</span><span style=\"color:#85E89D\">h2</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"space-y-8\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        {</span><span style=\"color:#6A737D\">/* Timeline entries here */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">section</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// In src/app/about/page.tsx</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> Journey </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@/components/Journey'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#F97583\"> function</span><span style=\"color:#B392F0\"> AboutPage</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">main</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"max-w-4xl mx-auto px-6 py-12\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#79B8FF\">Journey</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">main</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  )</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Better, but now I had another problem. How do I pass job data? I could hardcode it.. but that felt wrong. I spent some time debating whether to create a separate data file, use props, or just keep it simple.</p>\n<p>I ended up hardcoding it first because I wanted to see if the component would even work.</p>\n<h3>The Grid Layout Problem</h3>\n<p>The timeline needed to be responsive. On mobile, everything should stack vertically. On desktop, I wanted the date and title on the left, content on the right.</p>\n<p>My first attempt with Tailwind's grid system:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"grid grid-cols-1 lg:grid-cols-12 gap-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:col-span-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span><span style=\"color:#6A737D\">/* Date and title */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:col-span-8\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span><span style=\"color:#6A737D\">/* Content */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>This worked, but the alignment was weird. The date badge was floating, and the content didn't line up properly. I spent too long trying <code>flex-grow</code>, <code>gap-4</code>, <code>mt-4</code>, switching between <code>items-start</code> and <code>items-center</code> before realizing I needed to structure it differently.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// Second attempt - tried flexbox</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"flex flex-col lg:flex-row gap-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:w-1/3\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span><span style=\"color:#6A737D\">/* Date and title */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:w-2/3\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span><span style=\"color:#6A737D\">/* Content */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>This got the horizontal layout right but broke the vertical timeline effect. The border-left I was using for the timeline line wouldn't span the full height of the entry anymore - it got chopped off at the first child element.</p>\n<p>The alignment was weird because the date badge sat awkwardly in the left column, disconnected from the timeline border, and there was no way to make it overlap properly without complex positioning.</p>\n<p>What finally worked:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// Full entry structure</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"border-l-4 border-quantum-violet pl-6 relative\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"absolute -left-3 top-0\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">span</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"bg-quantum-violet text-white px-3 py-1 rounded-full text-sm\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      2014-2016</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">span</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  </span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"grid grid-cols-1 lg:grid-cols-12 gap-4 mt-2\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:col-span-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">h3</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"font-semibold\"</span><span style=\"color:#E1E4E8\">>Technical Support Engineer&#x3C;/</span><span style=\"color:#85E89D\">h3</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">span</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-neural-silver\"</span><span style=\"color:#E1E4E8\">>@ Dell EMC&#x3C;/</span><span style=\"color:#85E89D\">span</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lg:col-span-8\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">p</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-code-carbon mb-3\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        Learned systematic troubleshooting under pressure through the GSAP program.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;/</span><span style=\"color:#85E89D\">p</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"border-l-2 border-automation-amber pl-4\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;</span><span style=\"color:#85E89D\">p</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-sm text-automation-amber font-medium\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          Key Learning: Part of career growth is knowing when you're ready for the next challenge.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;/</span><span style=\"color:#85E89D\">p</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>The <code>absolute -left-3</code> positions the badge 0.75rem (3 * 0.25rem) to the left of the border. Since the border is <code>border-l-4</code> (1rem), this pulls the badge halfway across the border, creating that centered overlap effect I was going for.</p>\n<p>The key insight was using the border-left as the timeline line and positioning the date badge to overlap it. Obvious in hindsight, but it took me a couple of attempts to get there.</p>\n<h2>What I'd Do Differently (Looking Back)</h2>\n<p>If I were starting this component again, knowing what I know now:</p>\n<ul>\n<li>Start with a clear data structure. Don't overthink it but have a mental model of what job entries look like before writing JSX</li>\n<li>Use CSS custom properties more consistently instead of bouncing between semantic tokens and hardcoded values</li>\n<li>Build a proper prop interface from the beginning instead of refactoring later when I realized I needed more flexibility</li>\n</ul>\n<p>That said, the messy approach worked. It let me iterate quickly and focus on the content rather than getting stuck in architecture. A previous manager of mine once said \"Don't let perfect block the road to progress\" and that still resonates with me today. That was the approach I took here.</p>\n<p>The component finally worked. The layout was responsive, the date badges aligned, everything rendered correctly. I could have stopped there.</p>\n<h2>The Harder Part: Writing It</h2>\n<p>Building the component took a few hours including all the debugging. Writing the actual content took significantly more reflection.</p>\n<p>Looking back at each role, patterns I hadn't noticed before started to emerge.</p>\n<h3>Dell EMC (2014-2016)</h3>\n<p>This was my first proper job, and the GSAP program laid the essential foundation for my entire career. I learned systematic troubleshooting, how to communicate clearly when things are broken and someone's waiting on you. But the role also helped me understand my own value and my drive to take on more. Looking back, I was frustrated that there wasn't more room to grow. That's when I realized: I needed to find a culture that would let me meet that ambition head-on. Past-me was ready for the next challenge—I just hadn't figured out how to ask for it yet.</p>\n<h3>Nuix (2016-2020)</h3>\n<p>This job completely reshaped my definition of what technical support can be. I became a dedicated support resource for one of our largest customers in EMEA, working on-site with them, running demos for the sales team, and diving into the Engine APIs to write my own scripts. Great. But then I hit a wall—my passion and responsibilities had expanded, but the role hadn't. I spent way too long thinking I should just be grateful before realizing: no, I needed to find a place that would recognize that contribution. The irony was not lost on me that I'd outgrown the role that had taught me so much.</p>\n<h3>Cohesity - TSE (2020-2021)</h3>\n<p>I joined Cohesity in March 2020. Two weeks later, the world shut down. Starting a new job remotely, on a new product, after years of being the go-to person at Nuix—the imposter syndrome was real. I spent that year proving to myself that I could do it again. Learning a complex product from scratch, building new relationships through a screen, finding my footing in a company I'd never set foot in. Here's what I learned the hard way: your reputation doesn't transfer. Your ability to build one does.</p>\n<h3>Cohesity - Senior TSE (2021-2022)</h3>\n<p>The imposter syndrome from joining during Covid hadn't fully faded, but something had shifted. Management started routing the harder cases my way—complex database and backup issues that needed more than a standard playbook. I also knew early on that I wanted Tech Lead, so I stopped waiting for the title and started doing the job. Mentoring new hires, sharing what I knew, making myself useful beyond my own ticket queue. Honestly, I was surprised how quickly I stopped caring about the title once I started the actual work.</p>\n<h3>Cohesity - Technical Lead (2022-Present)</h3>\n<p>My title is Technical Lead, which in practice means I'm the person the team comes to when something doesn't make sense. As much as I love getting my hands on a weird, messy, complex problem, the real win for me isn't fixing it. It's turning around and showing my team how they can solve it too. I've also started noticing patterns beyond individual cases. Gaps in how we worked, how we shared knowledge. One change started as a tool I built without asking permission, shared with the team, and watched quietly become the new standard. I'm still facepalming that this worked so well—sometimes the best way to drive change is to build the thing first and ask for forgiveness later.</p>\n<h2>What I'm Taking From This</h2>\n<p>Writing this timeline forced me to articulate lessons I'd internalized but never expressed. I thought I knew what I learned at each job, but putting it on paper revealed patterns I hadn't seen.</p>\n<p>My path definitely wasn't a straight shot up. Lateral moves, expanded roles, finding opportunities to take on more - that was my actual growth. Growth is rarely linear.</p>\n<p>The technical skills matter (I can build the component), but understanding WHY I'm building it makes it meaningful. That's the part that sticks with you.</p>\n<p>I started writing this thinking I was just documenting where I'd been. I ended up learning something about where I'm going.</p>",
            "date_modified": "2025-11-24T00:00:00.000Z",
            "tags": [
                "reflections"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/adding-syntax-highlighting-shiki",
            "content_html": "<h2>Adding Syntax Highlighting to Velite with Shiki</h2>\n<p>I already had Velite working with Next.js (<a href=\"/blog/setting-up-velite-nextjs-revised\">covered in my previous post</a>), with posts in <code>/content/posts/*.md</code> and the dev server running on port 3000.</p>\n<p>My code blocks were just... sad. Monospace text on a dark background. No syntax highlighting, no nothing. Made my blog posts look like they were written in Notepad.</p>\n<p><strong>For reference, my versions:</strong></p>\n<ul>\n<li>Next.js ^16.1.1</li>\n<li>Velite ^0.3.0</li>\n<li>@shikijs/rehype ^3.20.0</li>\n<li>shiki ^3.15.0</li>\n<li>Node 20.18.0</li>\n</ul>\n<p>Things might differ with other versions, especially with Next.js 16.</p>\n<p>I wanted them to look like VS Code - proper colors for keywords, strings, functions, the whole deal. Shiki uses VS Code's actual syntax highlighting engine, so it seemed like the obvious choice.</p>\n<p>Turns out \"obvious\" doesn't mean \"straightforward.\" Here's what I ran into.</p>\n<h2>Setting Up Shiki</h2>\n<p>I needed two packages: Shiki itself, and the rehype plugin to hook it into Velite's markdown processing:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">npm</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#9ECBFF\"> @shikijs/rehype</span><span style=\"color:#9ECBFF\"> shiki</span></span></code></pre>\n<p>In my case, this grabbed <strong>shiki ^3.15.0</strong> and <strong>@shikijs/rehype ^3.20.0</strong>.</p>\n<p>Now it's time to hook this up in the Velite config. This is where I hit my first snag.</p>\n<h2>Problem #1: TypeScript Syntax in a JavaScript File</h2>\n<p>My first attempt:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> rehypeShiki </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@shikijs/rehype'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: { </span><span style=\"color:#6A737D\">/* ... */</span><span style=\"color:#E1E4E8\"> },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  markdown: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    rehypePlugins: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      [rehypeShiki </span><span style=\"color:#F97583\">as</span><span style=\"color:#79B8FF\"> any</span><span style=\"color:#E1E4E8\">, { theme: </span><span style=\"color:#9ECBFF\">'github-dark'</span><span style=\"color:#E1E4E8\"> }]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p>Error:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>Expected \"]\" but found \"as\"</span></span></code></pre>\n<p>The error was straightforward - my config file was <code>velite.config.js</code> (JavaScript), but I was using TypeScript syntax (<code>as any</code>). Just remove the type cast:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">markdown</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  rehypePlugins</span><span style=\"color:#E1E4E8\">: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    [rehypeShiki, { theme: </span><span style=\"color:#9ECBFF\">'github-dark'</span><span style=\"color:#E1E4E8\"> }]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h2>Problem #2: Config Structure</h2>\n<p>I kept getting syntax errors because I couldn't get the braces right. The <code>markdown</code> key needs to be at the same level as <code>collections</code>, not inside it:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// This doesn't work - markdown inside collections</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    posts: { </span><span style=\"color:#6A737D\">/* ... */</span><span style=\"color:#E1E4E8\"> },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    markdown: { </span><span style=\"color:#6A737D\">/* ... */</span><span style=\"color:#E1E4E8\"> }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// What actually works - markdown as sibling to collections</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    posts: { </span><span style=\"color:#6A737D\">/* ... */</span><span style=\"color:#E1E4E8\"> }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  markdown: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    rehypePlugins: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      [rehypeShiki, { theme: </span><span style=\"color:#9ECBFF\">'github-dark'</span><span style=\"color:#E1E4E8\"> }]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p>Count your braces carefully. I had to trace through mine multiple times.</p>\n<p>One more thing: after changing <code>velite.config.js</code>, I had to clear the cache or nothing would work:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">npm</span><span style=\"color:#9ECBFF\"> run</span><span style=\"color:#9ECBFF\"> clean:dev</span></span></code></pre>\n<p>Stop the dev server first, run the clean command, then restart. I forgot this the first time and spent 15 minutes wondering why nothing changed.</p>\n<h2>Problem #3: CSS Conflicts</h2>\n<p>Once Shiki was working, I had CSS fighting with it. My existing styles in <code>/src/app/globals.css</code> were overriding Shiki's colors.</p>\n<p>I restarted the dev server, refreshed the page, and... nothing had changed. Code blocks were still just monospace text in my custom colors.</p>\n<p>Wait, what? Shiki was supposed to be handling colors now.</p>\n<p>I opened DevTools and saw inline styles like <code>style=\"color: #ff79c6\"</code> on each code token. I realized Shiki generates these because it needs exact color control for syntax highlighting. My CSS's <code>text-color</code> property was more specific and overriding them.</p>\n<p>When I disabled <code>text-color</code> in DevTools, Shiki colors instantly appeared. So I just needed to remove that property from my CSS and let Shiki's colors come through.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">/* My initial approach - this was fighting with Shiki */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-code-carbon</span><span style=\"color:#79B8FF\"> text-neural-silver</span><span style=\"color:#79B8FF\"> px-</span><span style=\"color:#E1E4E8\">1.5 </span><span style=\"color:#79B8FF\">py-</span><span style=\"color:#E1E4E8\">0.5 </span><span style=\"color:#79B8FF\">rounded</span><span style=\"color:#79B8FF\"> font-mono</span><span style=\"color:#79B8FF\"> text-sm</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-code-carbon</span><span style=\"color:#E1E4E8\">/35 </span><span style=\"color:#79B8FF\">p-</span><span style=\"color:#E1E4E8\">4 </span><span style=\"color:#79B8FF\">rounded-lg</span><span style=\"color:#79B8FF\"> overflow-x-auto</span><span style=\"color:#79B8FF\"> font-mono</span><span style=\"color:#79B8FF\"> text-sm</span><span style=\"color:#79B8FF\"> m-</span><span style=\"color:#E1E4E8\">4;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-transparent</span><span style=\"color:#79B8FF\"> p-</span><span style=\"color:#E1E4E8\">0 </span><span style=\"color:#79B8FF\">text-code-carbon</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>The problem was that my colors were overriding Shiki's syntax highlighting.</p>\n<p>Here's what's happening: Shiki generates inline styles on each code token (like <code>style=\"color: #ff79c6\"</code> for keywords). My CSS was more specific and overriding these. What finally worked was letting Shiki handle colors for code blocks and only styling inline code myself:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">/* Inline code */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">article</span><span style=\"color:#B392F0\"> :not</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#85E89D\">pre</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">></span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">bg-terminal-primary</span><span style=\"color:#E1E4E8\">/15 !</span><span style=\"color:#79B8FF\">text-terminal-primary</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">px-</span><span style=\"color:#E1E4E8\">1.5 !</span><span style=\"color:#79B8FF\">py-</span><span style=\"color:#E1E4E8\">0.5 </span><span style=\"color:#79B8FF\">rounded-sm</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">font-mono</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">text-sm</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">border-none</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/* Code block container - let Shiki handle colors */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-terminal-dim</span><span style=\"color:#E1E4E8\">/10 </span><span style=\"color:#79B8FF\">p-</span><span style=\"color:#E1E4E8\">4 </span><span style=\"color:#79B8FF\">rounded-lg</span><span style=\"color:#79B8FF\"> border</span><span style=\"color:#79B8FF\"> border-terminal-dim</span><span style=\"color:#E1E4E8\">/15 </span><span style=\"color:#79B8FF\">font-mono</span><span style=\"color:#79B8FF\"> text-sm</span><span style=\"color:#79B8FF\"> overflow-x-auto</span><span style=\"color:#79B8FF\"> my-</span><span style=\"color:#E1E4E8\">6;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/* Reset for code inside pre */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">bg-transparent</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">p-</span><span style=\"color:#E1E4E8\">0 !</span><span style=\"color:#79B8FF\">border-none</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">rounded-none</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">text-inherit</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3>Verifying It Works</h3>\n<p>When I checked a blog post after getting the CSS right, I finally saw:</p>\n<ul>\n<li>Inline code (like <code>const foo = 'bar'</code> in a paragraph) had my custom background color</li>\n<li>Code blocks had colorized syntax highlighting:\n<ul>\n<li>Keywords (like <code>const</code>, <code>function</code>, <code>import</code>) in purple/pink</li>\n<li>Strings (like <code>'github-dark'</code>) in green</li>\n<li>Comments in muted gray</li>\n<li>Variables in light blue/white</li>\n</ul>\n</li>\n<li>In the example <code>const foo = 'bar'</code>, I saw <code>const</code> in one color and <code>'bar'</code> in a different color</li>\n</ul>\n<p>When my code blocks were still just monochrome text at first, I checked <a href=\"https://shiki.style/themes#github-dark\">Shiki's theme preview</a> to see what github-dark was supposed to look like. That's when I realized the Velite cache hadn't cleared properly. Ran <code>npm run clean:dev</code> one more time, restarted the dev server, and it worked.</p>\n<h2>Untagged Code Blocks Don't Get Highlighting</h2>\n<p>I noticed code blocks without a language specified don't get Shiki styling:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">```</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">This has no syntax highlighting</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">```</span></span></code></pre>\n<p>Two options:</p>\n<ol>\n<li>Always specify a language - use <code>text</code> or <code>plaintext</code> for non-code</li>\n<li>Add fallback text color in CSS:</li>\n</ol>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-transparent</span><span style=\"color:#79B8FF\"> p-</span><span style=\"color:#E1E4E8\">0 </span><span style=\"color:#79B8FF\">text-inherit</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>I went with always specifying languages since it's more explicit.</p>\n<h2>What Actually Worked</h2>\n<p>After all that debugging, here's what finally worked. Two files needed changes:</p>\n<p><strong>velite.config.js</strong> (mine's in the project root):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { defineConfig, s } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> rehypeShiki </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@shikijs/rehype'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    posts: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'Post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      schema: s</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          title: s.</span><span style=\"color:#B392F0\">string</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">max</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">99</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          slug: s.</span><span style=\"color:#B392F0\">slug</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'posts'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          date: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          cover: s.</span><span style=\"color:#B392F0\">image</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          video: s.</span><span style=\"color:#B392F0\">file</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          metadata: s.</span><span style=\"color:#B392F0\">metadata</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          excerpt: s.</span><span style=\"color:#B392F0\">excerpt</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          content: s.</span><span style=\"color:#B392F0\">markdown</span><span style=\"color:#E1E4E8\">()</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        })</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">transform</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">data</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">data, permalink: </span><span style=\"color:#9ECBFF\">`/blog/${</span><span style=\"color:#E1E4E8\">data</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">slug</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\"> }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  markdown: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    rehypePlugins: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      [rehypeShiki, { theme: </span><span style=\"color:#9ECBFF\">'github-dark'</span><span style=\"color:#E1E4E8\"> }]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p><strong>globals.css</strong> (at <code>/src/app/globals.css</code>):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">/* Inline code */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">article</span><span style=\"color:#B392F0\"> :not</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#85E89D\">pre</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">></span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">bg-terminal-primary</span><span style=\"color:#E1E4E8\">/15 !</span><span style=\"color:#79B8FF\">text-terminal-primary</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">px-</span><span style=\"color:#E1E4E8\">1.5 !</span><span style=\"color:#79B8FF\">py-</span><span style=\"color:#E1E4E8\">0.5 </span><span style=\"color:#79B8FF\">rounded-sm</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">font-mono</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">text-sm</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">border-none</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-terminal-dim</span><span style=\"color:#E1E4E8\">/10 </span><span style=\"color:#79B8FF\">p-</span><span style=\"color:#E1E4E8\">4 </span><span style=\"color:#79B8FF\">rounded-lg</span><span style=\"color:#79B8FF\"> border</span><span style=\"color:#79B8FF\"> border-terminal-dim</span><span style=\"color:#E1E4E8\">/15 </span><span style=\"color:#79B8FF\">font-mono</span><span style=\"color:#79B8FF\"> text-sm</span><span style=\"color:#79B8FF\"> overflow-x-auto</span><span style=\"color:#79B8FF\"> my-</span><span style=\"color:#E1E4E8\">6;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/* Reset prose code block styling - let Shiki handle it */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">bg-transparent</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">p-</span><span style=\"color:#E1E4E8\">0 !</span><span style=\"color:#79B8FF\">border-none</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">rounded-none</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">text-inherit</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Thinking back on it, the thing that would have saved me the most time was adding Shiki first. I spent time manually styling code blocks - choosing colors, tweaking spacing, getting everything just right. Then Shiki took over and I had to undo most of it.</p>\n<p>Other stuff that tripped me up:</p>\n<ul>\n<li>\n<p>File extensions matter - <code>.js</code> files can't use TypeScript syntax. Took me a few minutes of staring at <code>Expected \"]\" but found \"as\"</code> before I remembered the config was JavaScript, not TypeScript.</p>\n</li>\n<li>\n<p>Config structure is finicky - Velite has specific expectations about where keys go. Count your braces carefully.</p>\n</li>\n<li>\n<p>Specify languages in markdown - <code>```javascript</code> gives proper highlighting, but <code>```</code> without a language just shows plain text.</p>\n</li>\n</ul>\n<p>Syntax highlighting is working now and my code blocks actually look like code blocks. Took way longer than I expected, but at least they look like code blocks now.</p>",
            "url": "https://lukemanning.ie/blog/adding-syntax-highlighting-shiki",
            "title": "Adding Shiki for a pop of colour in my code blocks was a struggle",
            "summary": "<h2>Adding Syntax Highlighting to Velite with Shiki</h2>\n<p>I already had Velite working with Next.js (<a href=\"/blog/setting-up-velite-nextjs-revised\">covered in my previous post</a>), with posts in <code>/content/posts/*.md</code> and the dev server running on port 3000.</p>\n<p>My code blocks were just... sad. Monospace text on a dark background. No syntax highlighting, no nothing. Made my blog posts look like they were written in Notepad.</p>\n<p><strong>For reference, my versions:</strong></p>\n<ul>\n<li>Next.js ^16.1.1</li>\n<li>Velite ^0.3.0</li>\n<li>@shikijs/rehype ^3.20.0</li>\n<li>shiki ^3.15.0</li>\n<li>Node 20.18.0</li>\n</ul>\n<p>Things might differ with other versions, especially with Next.js 16.</p>\n<p>I wanted them to look like VS Code - proper colors for keywords, strings, functions, the whole deal. Shiki uses VS Code's actual syntax highlighting engine, so it seemed like the obvious choice.</p>\n<p>Turns out \"obvious\" doesn't mean \"straightforward.\" Here's what I ran into.</p>\n<h2>Setting Up Shiki</h2>\n<p>I needed two packages: Shiki itself, and the rehype plugin to hook it into Velite's markdown processing:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">npm</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#9ECBFF\"> @shikijs/rehype</span><span style=\"color:#9ECBFF\"> shiki</span></span></code></pre>\n<p>In my case, this grabbed <strong>shiki ^3.15.0</strong> and <strong>@shikijs/rehype ^3.20.0</strong>.</p>\n<p>Now it's time to hook this up in the Velite config. This is where I hit my first snag.</p>\n<h2>Problem #1: TypeScript Syntax in a JavaScript File</h2>\n<p>My first attempt:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> rehypeShiki </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@shikijs/rehype'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: { </span><span style=\"color:#6A737D\">/* ... */</span><span style=\"color:#E1E4E8\"> },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  markdown: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    rehypePlugins: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      [rehypeShiki </span><span style=\"color:#F97583\">as</span><span style=\"color:#79B8FF\"> any</span><span style=\"color:#E1E4E8\">, { theme: </span><span style=\"color:#9ECBFF\">'github-dark'</span><span style=\"color:#E1E4E8\"> }]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p>Error:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>Expected \"]\" but found \"as\"</span></span></code></pre>\n<p>The error was straightforward - my config file was <code>velite.config.js</code> (JavaScript), but I was using TypeScript syntax (<code>as any</code>). Just remove the type cast:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">markdown</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  rehypePlugins</span><span style=\"color:#E1E4E8\">: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    [rehypeShiki, { theme: </span><span style=\"color:#9ECBFF\">'github-dark'</span><span style=\"color:#E1E4E8\"> }]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h2>Problem #2: Config Structure</h2>\n<p>I kept getting syntax errors because I couldn't get the braces right. The <code>markdown</code> key needs to be at the same level as <code>collections</code>, not inside it:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">// This doesn't work - markdown inside collections</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    posts: { </span><span style=\"color:#6A737D\">/* ... */</span><span style=\"color:#E1E4E8\"> },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    markdown: { </span><span style=\"color:#6A737D\">/* ... */</span><span style=\"color:#E1E4E8\"> }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// What actually works - markdown as sibling to collections</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    posts: { </span><span style=\"color:#6A737D\">/* ... */</span><span style=\"color:#E1E4E8\"> }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  markdown: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    rehypePlugins: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      [rehypeShiki, { theme: </span><span style=\"color:#9ECBFF\">'github-dark'</span><span style=\"color:#E1E4E8\"> }]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p>Count your braces carefully. I had to trace through mine multiple times.</p>\n<p>One more thing: after changing <code>velite.config.js</code>, I had to clear the cache or nothing would work:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">npm</span><span style=\"color:#9ECBFF\"> run</span><span style=\"color:#9ECBFF\"> clean:dev</span></span></code></pre>\n<p>Stop the dev server first, run the clean command, then restart. I forgot this the first time and spent 15 minutes wondering why nothing changed.</p>\n<h2>Problem #3: CSS Conflicts</h2>\n<p>Once Shiki was working, I had CSS fighting with it. My existing styles in <code>/src/app/globals.css</code> were overriding Shiki's colors.</p>\n<p>I restarted the dev server, refreshed the page, and... nothing had changed. Code blocks were still just monospace text in my custom colors.</p>\n<p>Wait, what? Shiki was supposed to be handling colors now.</p>\n<p>I opened DevTools and saw inline styles like <code>style=\"color: #ff79c6\"</code> on each code token. I realized Shiki generates these because it needs exact color control for syntax highlighting. My CSS's <code>text-color</code> property was more specific and overriding them.</p>\n<p>When I disabled <code>text-color</code> in DevTools, Shiki colors instantly appeared. So I just needed to remove that property from my CSS and let Shiki's colors come through.</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">/* My initial approach - this was fighting with Shiki */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-code-carbon</span><span style=\"color:#79B8FF\"> text-neural-silver</span><span style=\"color:#79B8FF\"> px-</span><span style=\"color:#E1E4E8\">1.5 </span><span style=\"color:#79B8FF\">py-</span><span style=\"color:#E1E4E8\">0.5 </span><span style=\"color:#79B8FF\">rounded</span><span style=\"color:#79B8FF\"> font-mono</span><span style=\"color:#79B8FF\"> text-sm</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-code-carbon</span><span style=\"color:#E1E4E8\">/35 </span><span style=\"color:#79B8FF\">p-</span><span style=\"color:#E1E4E8\">4 </span><span style=\"color:#79B8FF\">rounded-lg</span><span style=\"color:#79B8FF\"> overflow-x-auto</span><span style=\"color:#79B8FF\"> font-mono</span><span style=\"color:#79B8FF\"> text-sm</span><span style=\"color:#79B8FF\"> m-</span><span style=\"color:#E1E4E8\">4;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-transparent</span><span style=\"color:#79B8FF\"> p-</span><span style=\"color:#E1E4E8\">0 </span><span style=\"color:#79B8FF\">text-code-carbon</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>The problem was that my colors were overriding Shiki's syntax highlighting.</p>\n<p>Here's what's happening: Shiki generates inline styles on each code token (like <code>style=\"color: #ff79c6\"</code> for keywords). My CSS was more specific and overriding these. What finally worked was letting Shiki handle colors for code blocks and only styling inline code myself:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">/* Inline code */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">article</span><span style=\"color:#B392F0\"> :not</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#85E89D\">pre</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">></span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">bg-terminal-primary</span><span style=\"color:#E1E4E8\">/15 !</span><span style=\"color:#79B8FF\">text-terminal-primary</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">px-</span><span style=\"color:#E1E4E8\">1.5 !</span><span style=\"color:#79B8FF\">py-</span><span style=\"color:#E1E4E8\">0.5 </span><span style=\"color:#79B8FF\">rounded-sm</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">font-mono</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">text-sm</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">border-none</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/* Code block container - let Shiki handle colors */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-terminal-dim</span><span style=\"color:#E1E4E8\">/10 </span><span style=\"color:#79B8FF\">p-</span><span style=\"color:#E1E4E8\">4 </span><span style=\"color:#79B8FF\">rounded-lg</span><span style=\"color:#79B8FF\"> border</span><span style=\"color:#79B8FF\"> border-terminal-dim</span><span style=\"color:#E1E4E8\">/15 </span><span style=\"color:#79B8FF\">font-mono</span><span style=\"color:#79B8FF\"> text-sm</span><span style=\"color:#79B8FF\"> overflow-x-auto</span><span style=\"color:#79B8FF\"> my-</span><span style=\"color:#E1E4E8\">6;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/* Reset for code inside pre */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">bg-transparent</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">p-</span><span style=\"color:#E1E4E8\">0 !</span><span style=\"color:#79B8FF\">border-none</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">rounded-none</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">text-inherit</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3>Verifying It Works</h3>\n<p>When I checked a blog post after getting the CSS right, I finally saw:</p>\n<ul>\n<li>Inline code (like <code>const foo = 'bar'</code> in a paragraph) had my custom background color</li>\n<li>Code blocks had colorized syntax highlighting:\n<ul>\n<li>Keywords (like <code>const</code>, <code>function</code>, <code>import</code>) in purple/pink</li>\n<li>Strings (like <code>'github-dark'</code>) in green</li>\n<li>Comments in muted gray</li>\n<li>Variables in light blue/white</li>\n</ul>\n</li>\n<li>In the example <code>const foo = 'bar'</code>, I saw <code>const</code> in one color and <code>'bar'</code> in a different color</li>\n</ul>\n<p>When my code blocks were still just monochrome text at first, I checked <a href=\"https://shiki.style/themes#github-dark\">Shiki's theme preview</a> to see what github-dark was supposed to look like. That's when I realized the Velite cache hadn't cleared properly. Ran <code>npm run clean:dev</code> one more time, restarted the dev server, and it worked.</p>\n<h2>Untagged Code Blocks Don't Get Highlighting</h2>\n<p>I noticed code blocks without a language specified don't get Shiki styling:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">```</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">This has no syntax highlighting</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">```</span></span></code></pre>\n<p>Two options:</p>\n<ol>\n<li>Always specify a language - use <code>text</code> or <code>plaintext</code> for non-code</li>\n<li>Add fallback text color in CSS:</li>\n</ol>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-transparent</span><span style=\"color:#79B8FF\"> p-</span><span style=\"color:#E1E4E8\">0 </span><span style=\"color:#79B8FF\">text-inherit</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>I went with always specifying languages since it's more explicit.</p>\n<h2>What Actually Worked</h2>\n<p>After all that debugging, here's what finally worked. Two files needed changes:</p>\n<p><strong>velite.config.js</strong> (mine's in the project root):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { defineConfig, s } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> rehypeShiki </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@shikijs/rehype'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    posts: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'Post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      schema: s</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          title: s.</span><span style=\"color:#B392F0\">string</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">max</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">99</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          slug: s.</span><span style=\"color:#B392F0\">slug</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'posts'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          date: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          cover: s.</span><span style=\"color:#B392F0\">image</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          video: s.</span><span style=\"color:#B392F0\">file</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          metadata: s.</span><span style=\"color:#B392F0\">metadata</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          excerpt: s.</span><span style=\"color:#B392F0\">excerpt</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          content: s.</span><span style=\"color:#B392F0\">markdown</span><span style=\"color:#E1E4E8\">()</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        })</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">transform</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">data</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">data, permalink: </span><span style=\"color:#9ECBFF\">`/blog/${</span><span style=\"color:#E1E4E8\">data</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">slug</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\"> }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  markdown: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    rehypePlugins: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      [rehypeShiki, { theme: </span><span style=\"color:#9ECBFF\">'github-dark'</span><span style=\"color:#E1E4E8\"> }]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p><strong>globals.css</strong> (at <code>/src/app/globals.css</code>):</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D\">/* Inline code */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">article</span><span style=\"color:#B392F0\"> :not</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#85E89D\">pre</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">></span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">bg-terminal-primary</span><span style=\"color:#E1E4E8\">/15 !</span><span style=\"color:#79B8FF\">text-terminal-primary</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">px-</span><span style=\"color:#E1E4E8\">1.5 !</span><span style=\"color:#79B8FF\">py-</span><span style=\"color:#E1E4E8\">0.5 </span><span style=\"color:#79B8FF\">rounded-sm</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">font-mono</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">text-sm</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">border-none</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#79B8FF\"> bg-terminal-dim</span><span style=\"color:#E1E4E8\">/10 </span><span style=\"color:#79B8FF\">p-</span><span style=\"color:#E1E4E8\">4 </span><span style=\"color:#79B8FF\">rounded-lg</span><span style=\"color:#79B8FF\"> border</span><span style=\"color:#79B8FF\"> border-terminal-dim</span><span style=\"color:#E1E4E8\">/15 </span><span style=\"color:#79B8FF\">font-mono</span><span style=\"color:#79B8FF\"> text-sm</span><span style=\"color:#79B8FF\"> overflow-x-auto</span><span style=\"color:#79B8FF\"> my-</span><span style=\"color:#E1E4E8\">6;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/* Reset prose code block styling - let Shiki handle it */</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">pre</span><span style=\"color:#85E89D\"> code</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  @</span><span style=\"color:#79B8FF\">apply</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">bg-transparent</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">p-</span><span style=\"color:#E1E4E8\">0 !</span><span style=\"color:#79B8FF\">border-none</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">rounded-none</span><span style=\"color:#E1E4E8\"> !</span><span style=\"color:#79B8FF\">text-inherit</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Thinking back on it, the thing that would have saved me the most time was adding Shiki first. I spent time manually styling code blocks - choosing colors, tweaking spacing, getting everything just right. Then Shiki took over and I had to undo most of it.</p>\n<p>Other stuff that tripped me up:</p>\n<ul>\n<li>\n<p>File extensions matter - <code>.js</code> files can't use TypeScript syntax. Took me a few minutes of staring at <code>Expected \"]\" but found \"as\"</code> before I remembered the config was JavaScript, not TypeScript.</p>\n</li>\n<li>\n<p>Config structure is finicky - Velite has specific expectations about where keys go. Count your braces carefully.</p>\n</li>\n<li>\n<p>Specify languages in markdown - <code>```javascript</code> gives proper highlighting, but <code>```</code> without a language just shows plain text.</p>\n</li>\n</ul>\n<p>Syntax highlighting is working now and my code blocks actually look like code blocks. Took way longer than I expected, but at least they look like code blocks now.</p>",
            "date_modified": "2025-11-23T00:00:00.000Z",
            "tags": [
                "velite",
                "nextjs"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/setting-up-velite-nextjs-revised",
            "content_html": "<h2>Setting Up Velite with Next.js 16</h2>\n<p>I spent hours yesterday fighting with Velite and Next.js 16. The docs said it should \"just work.\" Instead I got TypeScript errors about exports not found, build failures from missing files in the schema, and a type error that turned out to be a Next.js 16 breaking change.</p>\n<p>Here's what happened.</p>\n<hr>\n<h2>Where This Started</h2>\n<p>I got the idea to create a personal blog site where I can share what I'm working on. I did some research on various platforms, and consulted with Claude on potential options. In the end I decided a simple site based on static markdown files seemed like the most suitable option for my needs. I had no idea how to go about doing that so I googled it.</p>\n<p>Every article pointed to Contentlayer for managing a simple blog. Except Contentlayer isn't maintained anymore. So I did what any developer does: searched Reddit for \"what to use instead of Contentlayer.\" The consensus seemed to be that Velite was the successor.</p>\n<p>Velite offers a lot of additional features like build-time processing and Typescript generation, which I knew nothing about at that point, but those turned out to be useful features I'd use later. I just wanted markdown files to show up as blog posts and Velite seemed to be a suitable option for that.</p>\n<p>So I spun up a fresh Next.js project using <code>npx create-next-app@latest</code> and selected: TypeScript, ESLint, Tailwind CSS, App Router, Turbopack.</p>\n<p>Then I ran <code>npm install velite</code> and created a basic config.</p>\n<p>This is what my setup looked like:</p>\n<ul>\n<li>Node.js 20.10.0</li>\n<li>Next.js 16.1.1 with App Router</li>\n<li>React 19.2.0</li>\n<li>Velite 0.3.0</li>\n<li>TypeScript 5</li>\n<li>Running on Ubuntu via WSL2 using Turbopack</li>\n</ul>\n<p>File structure:</p>\n<pre><code>.\n├── velite.config.js\n├── next.config.ts\n├── tsconfig.json\n├── .velite/          # Generated by Velite\n├── posts/\n│   └── my-first-post.md\n└── app/\n    ├── page.tsx\n    └── blog/\n        └── [slug]/\n            └── page.tsx\n</code></pre>\n<hr>\n<h2>The First Problem: Imports Don't Work</h2>\n<p>I installed Velite, created <code>velite.config.js</code>, ran <code>npx velite</code>, and tried to import posts:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { posts } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite'</span><span style=\"color:#E1E4E8\">;</span></span></code></pre>\n<p>But immediately encountered a problem:\nError: <code>The export posts was not found in module velite/dist/index.js</code></p>\n<p>I stared at this for quite a few minutes. I assumed I'd import from the Velite package itself—that's how most npm packages work, right?</p>\n<p>Then I actually checked the <a href=\"https://velite.js.org/guide/quick-start\">Velite docs</a> again. Oh—you import from the <code>.velite</code> folder that Velite generates, not the package itself.</p>\n<p>So I tried:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { posts } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> './.velite'</span><span style=\"color:#E1E4E8\">;</span></span></code></pre>\n<p>That worked. But then I found <a href=\"https://velite.js.org/guide/using-collections\">the docs mentioned path aliases</a> as a cleaner option. So I created one in <code>tsconfig.json</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"compilerOptions\"</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    \"paths\"</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">      \"@/*\"</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"./*\"</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">      \"#site/content\"</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"./.velite\"</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>After restarting the dev server, I tested the path alias approach:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { posts } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '#site/content'</span><span style=\"color:#E1E4E8\">;</span></span></code></pre>\n<p>That worked.</p>\n<hr>\n<h2>The Second Problem: Velite Won't Run</h2>\n<p>So I tried running Velite:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>[VELITE] Patterns must be a string (non empty) or an array of strings</span></span></code></pre>\n<p>I checked my config—everything looked fine. I'd copied it straight from the <a href=\"https://velite.js.org/guide/quick-start\">Velite quick start docs</a>, so how could it be wrong?</p>\n<p>Then I noticed the docs had this <code>others</code> collection:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">collections</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  posts</span><span style=\"color:#E1E4E8\">: { </span><span style=\"color:#6A737D\">/* ... */</span><span style=\"color:#E1E4E8\"> },</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  others</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // other collection schema options</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>The <code>others</code> bit was just a placeholder showing you can have multiple collections. It wasn't required. I'd pasted it in verbatim without thinking about it.</p>\n<p>Velite was complaining because the <code>others</code> collection didn't have a <code>pattern</code> field (like <code>pattern: 'others/**/*.md'</code>). Without a pattern, Velite doesn't know what files to include.</p>\n<p>Deleted the <code>others</code> collection entirely. Ran <code>npx velite</code> again. This time: <code>✓ posts (1 documents)</code>.</p>\n<hr>\n<h2>The Third Problem: Missing Files Break Builds</h2>\n<p>Velite was running now. But looking at the <a href=\"https://velite.js.org/guide/quick-start\">Velite docs</a>, I saw examples with <code>cover</code> and <code>video</code> fields. Figured I'd add those too—why not?</p>\n<p>So I copied them into my schema:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">cover</span><span style=\"color:#E1E4E8\">: s.</span><span style=\"color:#B392F0\">image</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">video</span><span style=\"color:#E1E4E8\">: s.</span><span style=\"color:#B392F0\">file</span><span style=\"color:#E1E4E8\">(),</span></span></code></pre>\n<p>And put them in my test post:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">title</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">My First Post</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">slug</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">my-first-post</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">date</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2025-11-16</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">cover</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">cover.jpg</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">video</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">video.mp4</span></span></code></pre>\n<p>But these files didn't exist. Velite errored out.</p>\n<p>I should have started with a minimal post:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">title</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">My First Post</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">slug</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">my-first-post</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">date</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2025-11-16</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">---</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">This is some markdown content.</span></span></code></pre>\n<p>Same pattern as before—the docs were showing me what's possible, not what I actually need. I made them optional in my schema:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">cover</span><span style=\"color:#E1E4E8\">: s.</span><span style=\"color:#B392F0\">image</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">video</span><span style=\"color:#E1E4E8\">: s.</span><span style=\"color:#B392F0\">file</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span></code></pre>\n<p>Updated the schema, ran <code>npx velite</code>, and it worked.</p>\n<hr>\n<h2>The Fourth Problem: Dynamic Routes 404ing</h2>\n<p>I got to the point where I could import posts in a listing page, but individual post routes kept 404ing even though the files were there.</p>\n<p>I'd copied <a href=\"https://velite.js.org/guide/using-collections#use-in-your-project\">some example code from the docs</a> for dynamic routes:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#F97583\"> function</span><span style=\"color:#B392F0\"> PostPage</span><span style=\"color:#E1E4E8\">({ </span><span style=\"color:#FFAB70\">params</span><span style=\"color:#E1E4E8\"> }</span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#FFAB70\">params</span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#FFAB70\">slug</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\"> } }) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> post</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> posts.</span><span style=\"color:#B392F0\">find</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">p</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> p.slug </span><span style=\"color:#F97583\">===</span><span style=\"color:#E1E4E8\"> params.slug);</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>TypeScript was complaining that <code>params</code> should be a <code>Promise&#x3C;{ slug: string }></code> instead of just <code>{ slug: string }</code>. I ignored it at first because I assumed the example code was correct.</p>\n<p>Turns out the example was from Next.js 14 or earlier. Next.js 15 changed params to be async for better performance, and Next.js 16 kept that change.</p>\n<p>Here's what happens without <code>await</code>: Next.js expects an async function to handle the params promise. If you don't await it, the route handler won't execute properly—it just silently 404s.</p>\n<p>So I needed to await the params:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#F97583\"> async</span><span style=\"color:#F97583\"> function</span><span style=\"color:#B392F0\"> PostPage</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  params</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  params</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;{ </span><span style=\"color:#FFAB70\">slug</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\"> }></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">slug</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> params;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> post</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> posts.</span><span style=\"color:#B392F0\">find</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">p</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> p.slug </span><span style=\"color:#F97583\">===</span><span style=\"color:#E1E4E8\"> slug);</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ..</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>After this change, my dynamic routes loaded. No more 404s.</p>\n<hr>\n<h2>The Fifth Problem: Watch Mode Just... Didn't Work</h2>\n<p>This one took me the longest. I copied the integration code from the <a href=\"https://velite.js.org/guide/with-nextjs\">Velite docs</a> into <code>next.config.ts</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isDev</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.argv.</span><span style=\"color:#B392F0\">indexOf</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'dev'</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">!==</span><span style=\"color:#F97583\"> -</span><span style=\"color:#79B8FF\">1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isBuild</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.argv.</span><span style=\"color:#B392F0\">indexOf</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'build'</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">!==</span><span style=\"color:#F97583\"> -</span><span style=\"color:#79B8FF\">1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> (isDev </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> isBuild)) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> '1'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'velite'</span><span style=\"color:#E1E4E8\">).</span><span style=\"color:#B392F0\">then</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">m</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> m.</span><span style=\"color:#B392F0\">build</span><span style=\"color:#E1E4E8\">({ watch: isDev, clean: </span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isDev }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Started the dev server. No errors. But Velite never ran. I could edit markdown files all day—nothing happened.</p>\n<h3>Debugging the argv Issue</h3>\n<p>I'd been debugging for a while by this point and was stuck. I asked Claude for help, and it suggested adding some console.log statements to see what was actually in <code>process.argv</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'[DEBUG] isDev:'</span><span style=\"color:#E1E4E8\">, isDev, </span><span style=\"color:#9ECBFF\">'isBuild:'</span><span style=\"color:#E1E4E8\">, isBuild, </span><span style=\"color:#9ECBFF\">'argv:'</span><span style=\"color:#E1E4E8\">, process.argv)</span></span></code></pre>\n<p>Output showed both <code>isDev</code> and <code>isBuild</code> as false:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>[DEBUG] isDev: false isBuild: false argv: [</span></span>\n<span class=\"line\"><span>  '/home/luke/.nvm/versions/node/v24.11.1/bin/node',</span></span>\n<span class=\"line\"><span>  '/home/luke/workspace/lukemanning-site/node_modules/next/dist/server/lib/start-server.js'</span></span>\n<span class=\"line\"><span>]</span></span></code></pre>\n<p>So 'dev' and 'build' weren't in argv at all.</p>\n<p>The Velite integration code checks if 'dev' is in <code>process.argv</code> to decide whether to start watch mode. Since Turbopack's <code>start-server.js</code> doesn't include 'dev' in argv, that condition was never true—so Velite never started.</p>\n<p>Next.js was using <code>start-server.js</code> internally instead of a simple command-line argument.</p>\n<p>Claude suggested checking <code>process.env.NODE_ENV</code> as an alternative—since that's more reliable than parsing command-line arguments. Added that to my logging and saw it was set to 'development'. Worth a shot.</p>\n<p>Changed it:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isDev</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'development'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isBuild</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> (isDev </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> isBuild)) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> '1'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'velite'</span><span style=\"color:#E1E4E8\">).</span><span style=\"color:#B392F0\">then</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">m</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> m.</span><span style=\"color:#B392F0\">build</span><span style=\"color:#E1E4E8\">({ watch: isDev, clean: </span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isDev }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Started the dev server again:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>[VELITE] Building...</span></span>\n<span class=\"line\"><span>✓ posts (1 documents)</span></span>\n<span class=\"line\"><span>[VELITE] Watching for changes...</span></span></code></pre>\n<p>Edited a markdown file:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>[VELITE] Rebuilding...</span></span>\n<span class=\"line\"><span>✓ posts (1 documents)</span></span></code></pre>\n<p>Finally worked.</p>\n<p>I think what's happening is:</p>\n<ul>\n<li>Next.js 16 with Turbopack changed how the dev server starts internally</li>\n<li>It uses <code>start-server.js</code> as an intermediary instead of a simple <code>next dev</code> command</li>\n<li>The <code>NODE_ENV</code> environment variable is more reliable because it's set by Next.js regardless of how the server starts</li>\n</ul>\n<p>Turbopack is enabled by default in Next.js 16 when you choose the recommended defaults in create-next-app, so I didn't even realize I was using it. The Velite docs acknowledge that Turbopack breaks their webpack plugin, and they provide a <code>process.argv</code> workaround—but that workaround doesn't actually work with Next.js 16's Turbopack setup.</p>\n<p>My understanding could be wrong—I didn't dig into the Next.js source code—but the <code>NODE_ENV</code> approach has been rock-solid for two weeks now.</p>\n<hr>\n<h2>Where I Landed</h2>\n<p>After all that mess, here's where I ended up:</p>\n<p><strong>velite.config.js:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { defineConfig, s } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    posts: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'Post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      schema: s</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          title: s.</span><span style=\"color:#B392F0\">string</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">max</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">99</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          slug: s.</span><span style=\"color:#B392F0\">slug</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'posts'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          date: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          cover: s.</span><span style=\"color:#B392F0\">image</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          video: s.</span><span style=\"color:#B392F0\">file</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          metadata: s.</span><span style=\"color:#B392F0\">metadata</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          excerpt: s.</span><span style=\"color:#B392F0\">excerpt</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          content: s.</span><span style=\"color:#B392F0\">markdown</span><span style=\"color:#E1E4E8\">()</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        })</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">transform</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">data</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">data, permalink: </span><span style=\"color:#9ECBFF\">`/blog/${</span><span style=\"color:#E1E4E8\">data</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">slug</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\"> }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p><strong>next.config.ts:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#F97583\"> type</span><span style=\"color:#E1E4E8\"> { NextConfig } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> \"next\"</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isDev</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'development'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isBuild</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> (isDev </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> isBuild)) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> '1'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'velite'</span><span style=\"color:#E1E4E8\">).</span><span style=\"color:#B392F0\">then</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">m</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> m.</span><span style=\"color:#B392F0\">build</span><span style=\"color:#E1E4E8\">({ watch: isDev, clean: </span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isDev }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> nextConfig</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> NextConfig</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#E1E4E8\"> nextConfig;</span></span></code></pre>\n<p><strong>Just the paths section from tsconfig.json:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#9ECBFF\">\"paths\"</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"@/*\"</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"./*\"</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"#site/content\"</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"./.velite\"</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h2>Next Steps</h2>\n<p>Once you have Velite running with Next.js, you might also want to set up <a href=\"/blog/adding-draft-posts-to-velite\">draft post filtering</a> so unfinished posts don't accidentally go live, and <a href=\"/blog/adding-syntax-highlighting-shiki\">add syntax highlighting with Shiki</a> to make your code blocks actually readable.</p>",
            "url": "https://lukemanning.ie/blog/setting-up-velite-nextjs-revised",
            "title": "Setting Up Velite with Next.js 16",
            "summary": "<h2>Setting Up Velite with Next.js 16</h2>\n<p>I spent hours yesterday fighting with Velite and Next.js 16. The docs said it should \"just work.\" Instead I got TypeScript errors about exports not found, build failures from missing files in the schema, and a type error that turned out to be a Next.js 16 breaking change.</p>\n<p>Here's what happened.</p>\n<hr>\n<h2>Where This Started</h2>\n<p>I got the idea to create a personal blog site where I can share what I'm working on. I did some research on various platforms, and consulted with Claude on potential options. In the end I decided a simple site based on static markdown files seemed like the most suitable option for my needs. I had no idea how to go about doing that so I googled it.</p>\n<p>Every article pointed to Contentlayer for managing a simple blog. Except Contentlayer isn't maintained anymore. So I did what any developer does: searched Reddit for \"what to use instead of Contentlayer.\" The consensus seemed to be that Velite was the successor.</p>\n<p>Velite offers a lot of additional features like build-time processing and Typescript generation, which I knew nothing about at that point, but those turned out to be useful features I'd use later. I just wanted markdown files to show up as blog posts and Velite seemed to be a suitable option for that.</p>\n<p>So I spun up a fresh Next.js project using <code>npx create-next-app@latest</code> and selected: TypeScript, ESLint, Tailwind CSS, App Router, Turbopack.</p>\n<p>Then I ran <code>npm install velite</code> and created a basic config.</p>\n<p>This is what my setup looked like:</p>\n<ul>\n<li>Node.js 20.10.0</li>\n<li>Next.js 16.1.1 with App Router</li>\n<li>React 19.2.0</li>\n<li>Velite 0.3.0</li>\n<li>TypeScript 5</li>\n<li>Running on Ubuntu via WSL2 using Turbopack</li>\n</ul>\n<p>File structure:</p>\n<pre><code>.\n├── velite.config.js\n├── next.config.ts\n├── tsconfig.json\n├── .velite/          # Generated by Velite\n├── posts/\n│   └── my-first-post.md\n└── app/\n    ├── page.tsx\n    └── blog/\n        └── [slug]/\n            └── page.tsx\n</code></pre>\n<hr>\n<h2>The First Problem: Imports Don't Work</h2>\n<p>I installed Velite, created <code>velite.config.js</code>, ran <code>npx velite</code>, and tried to import posts:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { posts } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite'</span><span style=\"color:#E1E4E8\">;</span></span></code></pre>\n<p>But immediately encountered a problem:\nError: <code>The export posts was not found in module velite/dist/index.js</code></p>\n<p>I stared at this for quite a few minutes. I assumed I'd import from the Velite package itself—that's how most npm packages work, right?</p>\n<p>Then I actually checked the <a href=\"https://velite.js.org/guide/quick-start\">Velite docs</a> again. Oh—you import from the <code>.velite</code> folder that Velite generates, not the package itself.</p>\n<p>So I tried:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { posts } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> './.velite'</span><span style=\"color:#E1E4E8\">;</span></span></code></pre>\n<p>That worked. But then I found <a href=\"https://velite.js.org/guide/using-collections\">the docs mentioned path aliases</a> as a cleaner option. So I created one in <code>tsconfig.json</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"compilerOptions\"</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    \"paths\"</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">      \"@/*\"</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"./*\"</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">      \"#site/content\"</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"./.velite\"</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>After restarting the dev server, I tested the path alias approach:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { posts } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '#site/content'</span><span style=\"color:#E1E4E8\">;</span></span></code></pre>\n<p>That worked.</p>\n<hr>\n<h2>The Second Problem: Velite Won't Run</h2>\n<p>So I tried running Velite:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>[VELITE] Patterns must be a string (non empty) or an array of strings</span></span></code></pre>\n<p>I checked my config—everything looked fine. I'd copied it straight from the <a href=\"https://velite.js.org/guide/quick-start\">Velite quick start docs</a>, so how could it be wrong?</p>\n<p>Then I noticed the docs had this <code>others</code> collection:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">collections</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  posts</span><span style=\"color:#E1E4E8\">: { </span><span style=\"color:#6A737D\">/* ... */</span><span style=\"color:#E1E4E8\"> },</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  others</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // other collection schema options</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>The <code>others</code> bit was just a placeholder showing you can have multiple collections. It wasn't required. I'd pasted it in verbatim without thinking about it.</p>\n<p>Velite was complaining because the <code>others</code> collection didn't have a <code>pattern</code> field (like <code>pattern: 'others/**/*.md'</code>). Without a pattern, Velite doesn't know what files to include.</p>\n<p>Deleted the <code>others</code> collection entirely. Ran <code>npx velite</code> again. This time: <code>✓ posts (1 documents)</code>.</p>\n<hr>\n<h2>The Third Problem: Missing Files Break Builds</h2>\n<p>Velite was running now. But looking at the <a href=\"https://velite.js.org/guide/quick-start\">Velite docs</a>, I saw examples with <code>cover</code> and <code>video</code> fields. Figured I'd add those too—why not?</p>\n<p>So I copied them into my schema:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">cover</span><span style=\"color:#E1E4E8\">: s.</span><span style=\"color:#B392F0\">image</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">video</span><span style=\"color:#E1E4E8\">: s.</span><span style=\"color:#B392F0\">file</span><span style=\"color:#E1E4E8\">(),</span></span></code></pre>\n<p>And put them in my test post:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">title</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">My First Post</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">slug</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">my-first-post</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">date</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2025-11-16</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">cover</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">cover.jpg</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">video</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">video.mp4</span></span></code></pre>\n<p>But these files didn't exist. Velite errored out.</p>\n<p>I should have started with a minimal post:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">title</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">My First Post</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">slug</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">my-first-post</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">date</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">2025-11-16</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">---</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">This is some markdown content.</span></span></code></pre>\n<p>Same pattern as before—the docs were showing me what's possible, not what I actually need. I made them optional in my schema:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#B392F0\">cover</span><span style=\"color:#E1E4E8\">: s.</span><span style=\"color:#B392F0\">image</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">video</span><span style=\"color:#E1E4E8\">: s.</span><span style=\"color:#B392F0\">file</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span></code></pre>\n<p>Updated the schema, ran <code>npx velite</code>, and it worked.</p>\n<hr>\n<h2>The Fourth Problem: Dynamic Routes 404ing</h2>\n<p>I got to the point where I could import posts in a listing page, but individual post routes kept 404ing even though the files were there.</p>\n<p>I'd copied <a href=\"https://velite.js.org/guide/using-collections#use-in-your-project\">some example code from the docs</a> for dynamic routes:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#F97583\"> function</span><span style=\"color:#B392F0\"> PostPage</span><span style=\"color:#E1E4E8\">({ </span><span style=\"color:#FFAB70\">params</span><span style=\"color:#E1E4E8\"> }</span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#FFAB70\">params</span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#FFAB70\">slug</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\"> } }) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> post</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> posts.</span><span style=\"color:#B392F0\">find</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">p</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> p.slug </span><span style=\"color:#F97583\">===</span><span style=\"color:#E1E4E8\"> params.slug);</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>TypeScript was complaining that <code>params</code> should be a <code>Promise&#x3C;{ slug: string }></code> instead of just <code>{ slug: string }</code>. I ignored it at first because I assumed the example code was correct.</p>\n<p>Turns out the example was from Next.js 14 or earlier. Next.js 15 changed params to be async for better performance, and Next.js 16 kept that change.</p>\n<p>Here's what happens without <code>await</code>: Next.js expects an async function to handle the params promise. If you don't await it, the route handler won't execute properly—it just silently 404s.</p>\n<p>So I needed to await the params:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#F97583\"> async</span><span style=\"color:#F97583\"> function</span><span style=\"color:#B392F0\"> PostPage</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  params</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  params</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;{ </span><span style=\"color:#FFAB70\">slug</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\"> }></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">slug</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> params;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> post</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> posts.</span><span style=\"color:#B392F0\">find</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">p</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> p.slug </span><span style=\"color:#F97583\">===</span><span style=\"color:#E1E4E8\"> slug);</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ..</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>After this change, my dynamic routes loaded. No more 404s.</p>\n<hr>\n<h2>The Fifth Problem: Watch Mode Just... Didn't Work</h2>\n<p>This one took me the longest. I copied the integration code from the <a href=\"https://velite.js.org/guide/with-nextjs\">Velite docs</a> into <code>next.config.ts</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isDev</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.argv.</span><span style=\"color:#B392F0\">indexOf</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'dev'</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">!==</span><span style=\"color:#F97583\"> -</span><span style=\"color:#79B8FF\">1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isBuild</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.argv.</span><span style=\"color:#B392F0\">indexOf</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'build'</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">!==</span><span style=\"color:#F97583\"> -</span><span style=\"color:#79B8FF\">1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> (isDev </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> isBuild)) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> '1'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'velite'</span><span style=\"color:#E1E4E8\">).</span><span style=\"color:#B392F0\">then</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">m</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> m.</span><span style=\"color:#B392F0\">build</span><span style=\"color:#E1E4E8\">({ watch: isDev, clean: </span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isDev }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Started the dev server. No errors. But Velite never ran. I could edit markdown files all day—nothing happened.</p>\n<h3>Debugging the argv Issue</h3>\n<p>I'd been debugging for a while by this point and was stuck. I asked Claude for help, and it suggested adding some console.log statements to see what was actually in <code>process.argv</code>:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">console.</span><span style=\"color:#B392F0\">log</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'[DEBUG] isDev:'</span><span style=\"color:#E1E4E8\">, isDev, </span><span style=\"color:#9ECBFF\">'isBuild:'</span><span style=\"color:#E1E4E8\">, isBuild, </span><span style=\"color:#9ECBFF\">'argv:'</span><span style=\"color:#E1E4E8\">, process.argv)</span></span></code></pre>\n<p>Output showed both <code>isDev</code> and <code>isBuild</code> as false:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>[DEBUG] isDev: false isBuild: false argv: [</span></span>\n<span class=\"line\"><span>  '/home/luke/.nvm/versions/node/v24.11.1/bin/node',</span></span>\n<span class=\"line\"><span>  '/home/luke/workspace/lukemanning-site/node_modules/next/dist/server/lib/start-server.js'</span></span>\n<span class=\"line\"><span>]</span></span></code></pre>\n<p>So 'dev' and 'build' weren't in argv at all.</p>\n<p>The Velite integration code checks if 'dev' is in <code>process.argv</code> to decide whether to start watch mode. Since Turbopack's <code>start-server.js</code> doesn't include 'dev' in argv, that condition was never true—so Velite never started.</p>\n<p>Next.js was using <code>start-server.js</code> internally instead of a simple command-line argument.</p>\n<p>Claude suggested checking <code>process.env.NODE_ENV</code> as an alternative—since that's more reliable than parsing command-line arguments. Added that to my logging and saw it was set to 'development'. Worth a shot.</p>\n<p>Changed it:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isDev</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'development'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isBuild</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> (isDev </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> isBuild)) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> '1'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'velite'</span><span style=\"color:#E1E4E8\">).</span><span style=\"color:#B392F0\">then</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">m</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> m.</span><span style=\"color:#B392F0\">build</span><span style=\"color:#E1E4E8\">({ watch: isDev, clean: </span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isDev }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Started the dev server again:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>[VELITE] Building...</span></span>\n<span class=\"line\"><span>✓ posts (1 documents)</span></span>\n<span class=\"line\"><span>[VELITE] Watching for changes...</span></span></code></pre>\n<p>Edited a markdown file:</p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>[VELITE] Rebuilding...</span></span>\n<span class=\"line\"><span>✓ posts (1 documents)</span></span></code></pre>\n<p>Finally worked.</p>\n<p>I think what's happening is:</p>\n<ul>\n<li>Next.js 16 with Turbopack changed how the dev server starts internally</li>\n<li>It uses <code>start-server.js</code> as an intermediary instead of a simple <code>next dev</code> command</li>\n<li>The <code>NODE_ENV</code> environment variable is more reliable because it's set by Next.js regardless of how the server starts</li>\n</ul>\n<p>Turbopack is enabled by default in Next.js 16 when you choose the recommended defaults in create-next-app, so I didn't even realize I was using it. The Velite docs acknowledge that Turbopack breaks their webpack plugin, and they provide a <code>process.argv</code> workaround—but that workaround doesn't actually work with Next.js 16's Turbopack setup.</p>\n<p>My understanding could be wrong—I didn't dig into the Next.js source code—but the <code>NODE_ENV</code> approach has been rock-solid for two weeks now.</p>\n<hr>\n<h2>Where I Landed</h2>\n<p>After all that mess, here's where I ended up:</p>\n<p><strong>velite.config.js:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { defineConfig, s } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'velite'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  collections: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    posts: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'Post'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      pattern: </span><span style=\"color:#9ECBFF\">'posts/**/*.md'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      schema: s</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          title: s.</span><span style=\"color:#B392F0\">string</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">max</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">99</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          slug: s.</span><span style=\"color:#B392F0\">slug</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'posts'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          date: s.</span><span style=\"color:#B392F0\">isodate</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          cover: s.</span><span style=\"color:#B392F0\">image</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          video: s.</span><span style=\"color:#B392F0\">file</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">optional</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          metadata: s.</span><span style=\"color:#B392F0\">metadata</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          excerpt: s.</span><span style=\"color:#B392F0\">excerpt</span><span style=\"color:#E1E4E8\">(),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          content: s.</span><span style=\"color:#B392F0\">markdown</span><span style=\"color:#E1E4E8\">()</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        })</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        .</span><span style=\"color:#B392F0\">transform</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">data</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">data, permalink: </span><span style=\"color:#9ECBFF\">`/blog/${</span><span style=\"color:#E1E4E8\">data</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">slug</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\"> }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">})</span></span></code></pre>\n<p><strong>next.config.ts:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#F97583\"> type</span><span style=\"color:#E1E4E8\"> { NextConfig } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> \"next\"</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isDev</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'development'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isBuild</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#E1E4E8\"> (isDev </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> isBuild)) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  process.env.</span><span style=\"color:#79B8FF\">VELITE_STARTED</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> '1'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'velite'</span><span style=\"color:#E1E4E8\">).</span><span style=\"color:#B392F0\">then</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">m</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> m.</span><span style=\"color:#B392F0\">build</span><span style=\"color:#E1E4E8\">({ watch: isDev, clean: </span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isDev }))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> nextConfig</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> NextConfig</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#E1E4E8\"> nextConfig;</span></span></code></pre>\n<p><strong>Just the paths section from tsconfig.json:</strong></p>\n<pre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#9ECBFF\">\"paths\"</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"@/*\"</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"./*\"</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"#site/content\"</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"./.velite\"</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h2>Next Steps</h2>\n<p>Once you have Velite running with Next.js, you might also want to set up <a href=\"/blog/adding-draft-posts-to-velite\">draft post filtering</a> so unfinished posts don't accidentally go live, and <a href=\"/blog/adding-syntax-highlighting-shiki\">add syntax highlighting with Shiki</a> to make your code blocks actually readable.</p>",
            "date_modified": "2025-11-16T00:00:00.000Z",
            "tags": [
                "velite",
                "nextjs"
            ]
        },
        {
            "id": "https://lukemanning.ie/blog/the-entrepreneurial-conundrum",
            "content_html": "<blockquote>\n<p>Note: I actually wrote this on an older blog (Hence the publish date) but I wanted to resurrect the post here as I quite liked it. I remember sitting in a cafe typing this out on my phone. It felt oddly cathartic to write. Like finally admitting to myself that I spent more time thinking about ideas than executing ideas lifted a weight off my shoulders. That original blog is no more, but the content is still relevant, so I am re-posting it here.</p>\n</blockquote>\n<p>I have business ideas all the time. Every few days something pops into my head, usually triggered by some frustration or gap I notice in daily life. Sometimes I jot them down. I'll even flesh a few out, turn a vague thought into something that feels almost real.</p>\n<p>Then I lose interest. Or I realise I'm not actually passionate about it. Or I just... don't do anything.</p>\n<p>And then, inevitably, I see someone else ship the exact same thing. Usually some small tool or app I'd scribbled in my notes months earlier and never touched again. My first reaction is always \"That could have been me.\" It doesn't feel great.</p>\n<p>It's a cycle I've been in for years.</p>\n<h2>The 4 AM Ice Bath Thing</h2>\n<p>There was a stretch where I kept ending up in these YouTube spirals late at night. Start with something legit, then an hour later you're watching a guy in an ice bath telling you that success comes down to waking up at 4 AM and filling out your gratitude journal. You know the type.</p>\n<p>Some of them say it's about being uniquely positioned. Having the right resources, knowledge, and contacts at the right time. But if you're just starting out, how are you uniquely positioned? You're not. That's the point.</p>\n<p>Then there's the Alex Hormozi approach. \"Fake it till you make it.\" Grind long enough, build the expertise, make the contacts, and eventually something clicks.</p>\n<p>I've watched enough of these to know they all have a point. I've also watched enough to know none of them have the full picture. They all sound so sure of themselves. And I kept watching, so I guess it works.</p>\n<p>None of it really helped. The problem wasn't knowing how to start. It was starting.</p>\n<h2>The Reputation Fear</h2>\n<p>One of the YouTubers I kept coming back to was Dan Koe. He's the same guy who sent me down that self-actualisation rabbit hole I wrote about in my <a href=\"/blog/why-i-started-this-blog\">first post</a>. His stuff on personal branding made sense to me: build in public, share your journey, create an audience around what you're learning.</p>\n<p>But here's where I get stuck: if I ship something half-baked, does that damage the brand I'm trying to build? Too many missteps and people stop taking you seriously. Or at least, that's what I worry about. Maybe that's just an excuse to not ship anything.</p>\n<p>Then I look at Marc Louvion. I came across his profile on X a while back and started following along. He's had more failed startups than most people have ideas. He's transparent about all of them. And he's now followed by over 321k people specifically because of that transparency. He kept failing publicly and kept going.</p>\n<p>I read that and think: the reputation fear is overblown. Just ship stuff, be honest about what doesn't work, people respect that. Then I go to actually put something out there and the voice comes back. It'll look half-baked.</p>\n<h2>The Quote That Made Me Cringe</h2>\n<p>Reading <em>The Million Dollar Weekend</em> by Noah Kagan, I came across this:</p>\n<blockquote>\n<p>Okay, so you have an idea for an app. How would you go about doing it? Here's the way most people, most wantrepreneurs, would do it:</p>\n<ol>\n<li>Spend hours at home thinking about the app (and coming up with clever names for it).</li>\n<li>Spend $100 hiring your cousin to design a cool logo.</li>\n<li>Set up an LLC.</li>\n<li>Watch YouTube videos about apps, programming, and business.</li>\n<li>Consider signing up for a developer bootcamp and quickly realize coding is hard.</li>\n<li>Buy the domain name for the snazzy website you're going to build.</li>\n<li>Look into hiring a developer on UpWork and quickly realize it's cost prohibitive.</li>\n<li>Give up. Again.</li>\n</ol>\n<p><em>Noah Kagan, The Million Dollar Weekend</em></p>\n</blockquote>\n<p>I've done most of this list. Not all of it, but enough that reading it felt like being called out by name. The domain buying especially. I've bought domains for ideas that never went anywhere. They're still renewing.</p>\n<h2>Where I'm At</h2>\n<p>I'm still in the wantrepreneur camp. I know that. Reading that Noah Kagan excerpt didn't magically change anything, it just made the pattern visible.</p>\n<p>On the other side of this: <a href=\"/blog/the-day-i-achieved-nothing\">the reactive day problem</a> — even when you're building, it's easy to spend all your time answering other people's priorities and end up with nothing to show for it. The problem isn't just starting. It's protecting the space to actually execute.</p>\n<p>The conundrum for me isn't really about entrepreneurship at all. It's about the gap between having ideas and doing something with them. I know the theory. I've read the books, watched the videos, followed the people. Knowing the theory hasn't made me ship anything.</p>\n<p>I don't have a neat resolution here. I'm still working through it. Maybe writing about it is a small step. Maybe it's just another form of thinking about the app instead of building it. I genuinely don't know.</p>\n<p>And then there's <a href=\"/blog/posting-into-the-void\">the silence problem</a> — even when you do start, the void is real. The silence after shipping something you care about is its own obstacle to continuing.</p>",
            "url": "https://lukemanning.ie/blog/the-entrepreneurial-conundrum",
            "title": "The Entrepreneurial Conundrum",
            "summary": "<blockquote>\n<p>Note: I actually wrote this on an older blog (Hence the publish date) but I wanted to resurrect the post here as I quite liked it. I remember sitting in a cafe typing this out on my phone. It felt oddly cathartic to write. Like finally admitting to myself that I spent more time thinking about ideas than executing ideas lifted a weight off my shoulders. That original blog is no more, but the content is still relevant, so I am re-posting it here.</p>\n</blockquote>\n<p>I have business ideas all the time. Every few days something pops into my head, usually triggered by some frustration or gap I notice in daily life. Sometimes I jot them down. I'll even flesh a few out, turn a vague thought into something that feels almost real.</p>\n<p>Then I lose interest. Or I realise I'm not actually passionate about it. Or I just... don't do anything.</p>\n<p>And then, inevitably, I see someone else ship the exact same thing. Usually some small tool or app I'd scribbled in my notes months earlier and never touched again. My first reaction is always \"That could have been me.\" It doesn't feel great.</p>\n<p>It's a cycle I've been in for years.</p>\n<h2>The 4 AM Ice Bath Thing</h2>\n<p>There was a stretch where I kept ending up in these YouTube spirals late at night. Start with something legit, then an hour later you're watching a guy in an ice bath telling you that success comes down to waking up at 4 AM and filling out your gratitude journal. You know the type.</p>\n<p>Some of them say it's about being uniquely positioned. Having the right resources, knowledge, and contacts at the right time. But if you're just starting out, how are you uniquely positioned? You're not. That's the point.</p>\n<p>Then there's the Alex Hormozi approach. \"Fake it till you make it.\" Grind long enough, build the expertise, make the contacts, and eventually something clicks.</p>\n<p>I've watched enough of these to know they all have a point. I've also watched enough to know none of them have the full picture. They all sound so sure of themselves. And I kept watching, so I guess it works.</p>\n<p>None of it really helped. The problem wasn't knowing how to start. It was starting.</p>\n<h2>The Reputation Fear</h2>\n<p>One of the YouTubers I kept coming back to was Dan Koe. He's the same guy who sent me down that self-actualisation rabbit hole I wrote about in my <a href=\"/blog/why-i-started-this-blog\">first post</a>. His stuff on personal branding made sense to me: build in public, share your journey, create an audience around what you're learning.</p>\n<p>But here's where I get stuck: if I ship something half-baked, does that damage the brand I'm trying to build? Too many missteps and people stop taking you seriously. Or at least, that's what I worry about. Maybe that's just an excuse to not ship anything.</p>\n<p>Then I look at Marc Louvion. I came across his profile on X a while back and started following along. He's had more failed startups than most people have ideas. He's transparent about all of them. And he's now followed by over 321k people specifically because of that transparency. He kept failing publicly and kept going.</p>\n<p>I read that and think: the reputation fear is overblown. Just ship stuff, be honest about what doesn't work, people respect that. Then I go to actually put something out there and the voice comes back. It'll look half-baked.</p>\n<h2>The Quote That Made Me Cringe</h2>\n<p>Reading <em>The Million Dollar Weekend</em> by Noah Kagan, I came across this:</p>\n<blockquote>\n<p>Okay, so you have an idea for an app. How would you go about doing it? Here's the way most people, most wantrepreneurs, would do it:</p>\n<ol>\n<li>Spend hours at home thinking about the app (and coming up with clever names for it).</li>\n<li>Spend $100 hiring your cousin to design a cool logo.</li>\n<li>Set up an LLC.</li>\n<li>Watch YouTube videos about apps, programming, and business.</li>\n<li>Consider signing up for a developer bootcamp and quickly realize coding is hard.</li>\n<li>Buy the domain name for the snazzy website you're going to build.</li>\n<li>Look into hiring a developer on UpWork and quickly realize it's cost prohibitive.</li>\n<li>Give up. Again.</li>\n</ol>\n<p><em>Noah Kagan, The Million Dollar Weekend</em></p>\n</blockquote>\n<p>I've done most of this list. Not all of it, but enough that reading it felt like being called out by name. The domain buying especially. I've bought domains for ideas that never went anywhere. They're still renewing.</p>\n<h2>Where I'm At</h2>\n<p>I'm still in the wantrepreneur camp. I know that. Reading that Noah Kagan excerpt didn't magically change anything, it just made the pattern visible.</p>\n<p>On the other side of this: <a href=\"/blog/the-day-i-achieved-nothing\">the reactive day problem</a> — even when you're building, it's easy to spend all your time answering other people's priorities and end up with nothing to show for it. The problem isn't just starting. It's protecting the space to actually execute.</p>\n<p>The conundrum for me isn't really about entrepreneurship at all. It's about the gap between having ideas and doing something with them. I know the theory. I've read the books, watched the videos, followed the people. Knowing the theory hasn't made me ship anything.</p>\n<p>I don't have a neat resolution here. I'm still working through it. Maybe writing about it is a small step. Maybe it's just another form of thinking about the app instead of building it. I genuinely don't know.</p>\n<p>And then there's <a href=\"/blog/posting-into-the-void\">the silence problem</a> — even when you do start, the void is real. The silence after shipping something you care about is its own obstacle to continuing.</p>",
            "date_modified": "2024-08-13T00:00:00.000Z",
            "tags": [
                "reflections"
            ]
        }
    ]
}