[{"data":1,"prerenderedAt":1822},["ShallowReactive",2],{"doc":3,"all-blog-posts-nav":254},{"id":4,"title":5,"body":6,"created_at":235,"description":236,"extension":237,"image":238,"imageAlt":239,"imagePrompt":240,"meta":241,"navigation":242,"path":243,"published":242,"seo":244,"stem":245,"tags":246,"updated_at":252,"__hash__":253},"blog/blog/hands-off-engineering.md","Hands-off engineering",{"type":7,"value":8,"toc":217},"minimark",[9,13,17,20,23,28,38,50,54,57,60,64,75,78,82,85,90,93,96,99,102,106,109,112,115,118,122,125,128,131,135,138,141,144,147,151,158,165,169,172,175,179,182,186,189,214],[10,11,5],"h1",{"id":12},"hands-off-engineering",[14,15,16],"p",{},"My work changed drastically in November 2025, when Opus 4.5 arrived. For the first time I could trust the code an agent produced, as long as I kept an eye on it. Trust, but verify.",[14,18,19],{},"Half a year later, the watching is going away too. With Sonnet 5 and Opus 4.8 running under an orchestration layer, I hardly open the CLI anymore. Work goes in as a spec and comes out as a merged MR. I built that layer with Fable, Anthropic's newest model, while it was temporarily available to me. The result is Sandcastle: a pipeline that picks up the projects I brainstorm and implements them while I do something else.",[14,21,22],{},"This post is a tour of the setup, with a proper deep dive into what happens during a single run.",[24,25,27],"h2",{"id":26},"what-sandcastle-is","What Sandcastle is",[14,29,30,37],{},[31,32,36],"a",{"href":33,"rel":34},"https://www.npmjs.com/package/@ai-hero/sandcastle",[35],"nofollow","sandcastle"," is Matt Pocock's library for running a coding agent in a disposable sandbox. It creates a git worktree on a fresh branch and mounts it into a Docker container that runs Claude Code. That is all it does, and that is deliberate.",[14,39,40,41,45,46,49],{},"Everything around it is custom. My orchestration layer lives in a ",[42,43,44],"code",{},".sandcastle/"," folder at the root of the monorepo I work on, a pnpm workspace with several Nuxt apps on Postgres. It connects three systems: Linear holds the work, GitLab holds the merge requests and CI, and cron on my machine is the heartbeat. One command, ",[42,47,48],{},"pnpm sandcastle:project",", drains an entire Linear project.",[24,51,53],{"id":52},"from-brainstorm-to-queue","From brainstorm to queue",[14,55,56],{},"Work starts as a brainstorm session with Claude. The brainstorm becomes a PRD, the PRD becomes a Linear project, and the project is cut into small issues wired together with blocked-by relations. None of that needs a terminal; it happens in conversation and lands in Linear.",[14,58,59],{},"Statuses are the contract. I move an issue to Ready for Agent, and from that point the pipeline owns it: In Progress, In Review, and finally Done, or Needs Info when it wants a human. The orchestrator keeps no database of its own. Everything it must remember lives on the issue itself, as a status plus an attached MR link with a typed prefix like \"Auto-merging MR:\". Kill the process at any moment and the next run rebuilds reality from Linear and GitLab.",[24,61,63],{"id":62},"the-loop","The loop",[14,65,66,67,70,71,74],{},"Nothing runs permanently. Cron fires every fifteen minutes and executes a single tick: reconcile what happened while nobody was looking, plan which issues can run, run them a couple of sandboxes at a time, and finalize the project with a roll-up MR once everything is done. Then the process exits. Planning is a topological sort over the blocked-by graph, so an issue only starts when its blockers are finished, which is also what makes running sandboxes in parallel safe. A ",[42,68,69],{},"flock"," lock keeps ticks single-flight, and a ",[42,72,73],{},"--watch"," flag loops the same tick for when I want to sit and watch a drain live.",[14,76,77],{},"Why polling instead of webhooks? GitLab.com cannot reach into my WSL2 machine, and a loop that re-derives all state every tick is far easier to make crash-safe anyway.",[24,79,81],{"id":80},"inside-one-issue-run","Inside one issue run",[14,83,84],{},"This is the part worth slowing down for. The run is where the engineering happens.",[86,87,89],"h3",{"id":88},"building-the-world","Building the world",[14,91,92],{},"The pool picks a runnable issue and immediately moves it to In Progress, before any agent wakes up. Then the sandbox is built: a fresh git worktree on its own branch, bind-mounted into a Docker container with Node, pnpm, Postgres 16 and the Claude Code CLI. The host's pnpm store is mounted in as well, so installing dependencies is mostly a linking exercise instead of a download.",[14,94,95],{},"Cold start is a shell script with a twenty minute ceiling, and it does the boring work agents are bad at. It boots a throwaway Postgres cluster inside the container, copies in secret-free env templates, runs a frozen-lockfile install, builds every workspace package, pushes the database schema and seeds fixtures. That throwaway cluster matters more than it looks: every sandbox gets its own database, so parallel runs can migrate and seed without ever touching each other.",[14,97,98],{},"Then a smoke script gates the run. Is the database reachable, does typecheck pass, does one representative test go green? If the environment is broken, the run dies here, cheaply, instead of an agent spending an hour fighting a broken world and concluding the code must be at fault.",[14,100,101],{},"Only now does a model get involved.",[86,103,105],{"id":104},"the-implementer","The implementer",[14,107,108],{},"The implementer is Sonnet, working test-first, with a budget of roughly eight agent iterations. Its prompt contains the issue, the surrounding PRD context, and every comment on the Linear issue, with an explicit rule that the newest human comment outranks the issue body. That last part is the steering wheel. If a run went sideways yesterday, I write one comment on the issue and the next attempt starts with my correction on top, no restart required.",[14,110,111],{},"The prompt is also full of things the agent must not do. No pushing, no opening MRs, no touching remotes; the host does all of that. No history-altering git commands either, a rule with a story behind it that I will get to. And comments are marked as direction to weigh, not instructions to execute, so a stray command in a Linear comment never becomes something the agent runs.",[14,113,114],{},"Its one hard obligation is to commit its work. A run that produces zero commits is treated as a failure, no matter how confident the final summary sounds.",[14,116,117],{},"When it finishes, the host posts that summary as a comment on the Linear issue. The thread slowly becomes a progress log.",[86,119,121],{"id":120},"the-reviewer","The reviewer",[14,123,124],{},"Then the sandbox gets a second visitor. The reviewer is Opus at maximum reasoning effort, and it gets exactly one iteration in a completely fresh session. It has not seen the implementer's chat, its plans, or its excuses. It sees the issue, the diff and the tests, and it writes a single file: a verdict that starts with either APPROVE or CHANGES REQUESTED.",[14,126,127],{},"The fresh session is the whole point. A reviewer that inherits the implementer's context inherits its blind spots too, and will happily approve its own reasoning back to itself. A different model in a clean context is the closest thing to a real colleague I can simulate.",[14,129,130],{},"CHANGES REQUESTED buys one rework round in the same warm sandbox, followed by another fresh review. If the second verdict is still unhappy, the loop stops and a human takes over. Agents can argue with each other forever, so I do not let them.",[86,132,134],{"id":133},"publishing","Publishing",[14,136,137],{},"Everything after the verdict happens on the host, outside the sandbox. The host pushes the branch and opens the MR with the verdict as its description. For approved work it also arms GitLab's merge-when-pipeline-succeeds. GitLab refuses auto-merge for a short window right after an MR is created, so the arm call retries with backoff. Small, dumb, essential.",[14,139,140],{},"Note what actually merges the code: not the implementer's confidence, not even the reviewer's approval, but a green CI pipeline. The agents propose; CI disposes.",[14,142,143],{},"The Linear issue follows along. Merged straight away means Done, with a link to the MR. Armed but still waiting on the pipeline means In Review with that \"Auto-merging MR:\" marker, which a later tick reads to promote the issue to Done, or to notice the auto-merge was dropped and send it to triage. Changes requested means a draft MR and the verdict posted as a comment. And when the reviewer flags useful work that is out of scope, its follow-up notes are filed automatically as new Backlog issues.",[14,145,146],{},"One issue in, one MR out, and a paper trail on the issue I can read from my phone.",[24,148,150],{"id":149},"the-host-holds-the-keys","The host holds the keys",[14,152,153,154,157],{},"The sandbox receives exactly one secret: the Claude Code OAuth token. The Linear API key and the GitLab token stay on the host, so the agent cannot push, cannot open MRs and cannot touch an issue even if it wanted to. All writeback is plain host-side code, Linear through its GraphQL API and GitLab through the ",[42,155,156],{},"glab"," CLI. State transitions are deterministic code paths, not something a model decides to do with a tool call.",[14,159,160,161,164],{},"That boundary earned its keep during the first live drain. The git worktree shares the host's object store, and one agent, trying to tidy up its workspace, ran ",[42,162,163],{},"git stash pop",". It pulled a stash from my own working copy into its sandbox. Nothing was lost, but the lesson was clear: a container is not a git boundary. The prompt ban on destructive git commands exists because of that afternoon.",[24,166,168],{"id":167},"when-it-goes-wrong","When it goes wrong",[14,170,171],{},"Failure handling is deliberately unsophisticated. There are no retry counters. A failed run moves its issue to Needs Info with a comment pointing at the log, and that is it; nothing gets retried until a human moves the issue back. The exceptions are narrow. An expired Claude token re-queues the issue untouched and stops the whole batch, because a dead token would take down every run after it. A merge conflict gets one cheap triage pass that picks between replaying the work on a fresh base, redoing it, or escalating with a diagnosis. A second conflict always goes to a human.",[14,173,174],{},"Crashes are handled by the reconcile step at the start of each tick. Ticks are single-flight, so an In Progress issue without a live run is by definition an orphan, and its branch tells the story: no MR means reset the issue, a merged MR means promote it to Done, an open MR means park it In Review.",[24,176,178],{"id":177},"what-it-runs-on","What it runs on",[14,180,181],{},"There is no API key involved anywhere. The sandboxes authenticate with the same Claude Code Max subscription I use interactively, which makes the weekly usage cap the real constraint. A small monitor warns me when a weekly window passes eighty percent. The model split follows the money: Sonnet does the many iterations of implementation, Opus spends one expensive pass on judgment.",[24,183,185],{"id":184},"if-you-build-one-yourself","If you build one yourself",[14,187,188],{},"The code is not the transferable part. The design choices are, and these are the ones I would defend:",[190,191,192,196,199,202,205,208,211],"ul",{},[193,194,195],"li",{},"Keep durable state in your tracker, not in your orchestrator. Statuses and attached links survive crashes; process memory does not.",[193,197,198],{},"Let the host perform every side effect. The agent writes code and nothing else, and credentials never enter the sandbox.",[193,200,201],{},"Spend on the environment before the agent. A built, seeded, smoke-tested sandbox saves more tokens than any prompt tweak.",[193,203,204],{},"Review in a fresh context. A reviewer that inherits the implementer's session inherits its blind spots.",[193,206,207],{},"Failures should leave the queue, not retry. A counter only delays a loop; a human breaks it.",[193,209,210],{},"Make CI the merge gate, never the agent's confidence.",[193,212,213],{},"Build the steering channel early. Redirecting a live system with a comment beats killing and restarting it.",[14,215,216],{},"And to be honest about the hands-off part: I still write the specs, triage the queue, and read the roll-up MR before it reaches main. But the CLI has become the exception. From now on, the brainstorm is the work.",{"title":218,"searchDepth":219,"depth":220,"links":221},"",2,3,[222,223,224,225,231,232,233,234],{"id":26,"depth":219,"text":27},{"id":52,"depth":219,"text":53},{"id":62,"depth":219,"text":63},{"id":80,"depth":219,"text":81,"children":226},[227,228,229,230],{"id":88,"depth":220,"text":89},{"id":104,"depth":220,"text":105},{"id":120,"depth":220,"text":121},{"id":133,"depth":220,"text":134},{"id":149,"depth":219,"text":150},{"id":167,"depth":219,"text":168},{"id":177,"depth":219,"text":178},{"id":184,"depth":219,"text":185},"2026-07-03T00:00:00.000Z","How a Linear project full of issues becomes merged code through sandboxed Claude Code agents, with a deep dive into the anatomy of a single run.","md","/images/blog/hands-off-engineering.png","Hero image for \"Hands-off engineering\": Abstract geometric pathways merging, data streams flowing into a central nexus representing automati","Abstract geometric pathways merging, data streams flowing into a central nexus representing automation, deep blue and purple, digital clean aesthetic",{},true,"/blog/hands-off-engineering",{"title":5,"description":236},"blog/hands-off-engineering",[247,248,249,250,251],"Claude Code","AI","Automation","Linear","GitLab",null,"ED29PDJgHWYHUBMxRV4skmZ7xhwx9KwhqHyJ0lPOnB4",[255,293,338,497,566,1524,1680],{"id":256,"title":257,"body":258,"created_at":281,"description":282,"extension":237,"image":283,"imageAlt":284,"imagePrompt":285,"meta":286,"navigation":242,"path":287,"published":242,"seo":288,"stem":289,"tags":290,"updated_at":252,"__hash__":292},"blog/blog/hello_world.md","Hello, World!",{"type":7,"value":259,"toc":279},[260,263,266,276],[10,261,257],{"id":262},"hello-world",[14,264,265],{},"Starting a blog. Not entirely sure what it will become, but here we are.",[14,267,268,269,275],{},"I built this site as a ",[31,270,274],{":target":271,"href":272,"rel":273},"_blank","https://www.nuxt.com",[35],"Nuxt"," application. The process of designing and building it was fun, and I plan to write about that and other personal projects going forward.",[14,277,278],{},"No grand plan yet. I will figure out the direction as I go. For now, this is the obligatory first post. Hello, world.",{"title":218,"searchDepth":219,"depth":220,"links":280},[],"2023-04-05T00:00:00.000Z","First post: Hello, World!","/images/blog/hello_world.png","Hero image for \"hello_world\": Luminescent portal opening to infinite possibilities, soft geometric patterns emerging from center, warm purple and gold accents, ethereal dawn atmosphere","Luminescent portal opening to infinite possibilities, soft geometric patterns emerging from center, warm purple and gold accents, ethereal dawn atmosphere",{},"/blog/hello_world",{"title":257,"description":282},"blog/hello_world",[291],"Hello world","4MPrnD_T5MZIskYWdwgIL2vhqo7NPGbUaXosulAz_zs",{"id":294,"title":295,"body":296,"created_at":328,"description":329,"extension":237,"image":330,"imageAlt":331,"imagePrompt":332,"meta":333,"navigation":242,"path":334,"published":242,"seo":335,"stem":336,"tags":252,"updated_at":252,"__hash__":337},"blog/blog/plans.md","Plans for this site",{"type":7,"value":297,"toc":326},[298,301,304,311,317,323],[10,299,295],{"id":300},"plans-for-this-site",[14,302,303],{},"Writing things down helps me think, and making those plans public keeps me honest. So here is what I have in mind for this site.",[14,305,306,310],{},[307,308,309],"strong",{},"More technical writing."," I want to write about the tools and patterns I use at work and in personal projects. Not tutorials aimed at beginners, but the kind of posts I would want to read myself when figuring something out.",[14,312,313,316],{},[307,314,315],{},"A newsletter."," At some point I want to add an email option for people who prefer that over checking a website. No pressure, no schedule, just a notification when something new goes up.",[14,318,319,322],{},[307,320,321],{},"Better discoverability."," The site exists, but nobody knows about it. Some basic SEO work and maybe sharing posts in the right places would help.",[14,324,325],{},"That is roughly it for now. I will update this as things change.",{"title":218,"searchDepth":219,"depth":220,"links":327},[],"2023-04-08T00:00:00.000Z","What I want to do with this site and where I see it going.","/images/blog/plans.png","Hero image for \"plans\": Abstract interconnected pathways of light forming constellation map, nodes glowing with potential, deep indigo with teal accents, futuristic navigation","Abstract interconnected pathways of light forming constellation map, nodes glowing with potential, deep indigo with teal accents, futuristic navigation",{},"/blog/plans",{"title":295,"description":329},"blog/plans","XFKplItQf4sT2T5mrv3DSko-TsA7g7TpMYg3wU6VsBs",{"id":339,"title":340,"body":341,"created_at":484,"description":485,"extension":237,"image":486,"imageAlt":487,"imagePrompt":488,"meta":489,"navigation":242,"path":490,"published":242,"seo":491,"stem":492,"tags":493,"updated_at":252,"__hash__":496},"blog/blog/github_copilot_experience.md","First time using Github Copilot",{"type":7,"value":342,"toc":480},[343,346,349,353,360,376,379,460,463,467,470,473,476],[10,344,340],{"id":345},"first-time-using-github-copilot",[14,347,348],{},"I had Copilot enabled for a while but never really paid attention to its suggestions. Then I needed to add a dynamic copyright year to the footer of this site, and figured it was a good excuse to actually try it properly.",[24,350,352],{"id":351},"the-test","The test",[14,354,355,356,359],{},"I opened ",[42,357,358],{},"components/Footer.vue"," and typed a comment:",[361,362,366],"pre",{"className":363,"code":364,"language":365,"meta":218,"style":218},"language-html shiki shiki-themes github-light github-dark","\u003C!-- Generate dynamic copyright year -->\n","html",[42,367,368],{"__ignoreMap":218},[369,370,373],"span",{"class":371,"line":372},"line",1,[369,374,364],{"class":375},"sJ8bj",[14,377,378],{},"Copilot suggested this:",[361,380,382],{"className":363,"code":381,"language":365,"meta":218,"style":218},"\u003Csmall class=\"mb-2 flex space-x-2 text-sm text-slate-500 dark:text-slate-400\">\n  \u003Cdiv>Martijn Bos\u003C/div>\n  \u003Cdiv>•\u003C/div>\n  \u003Cdiv>© {{ new Date().getFullYear() }}\u003C/div>\n\u003C/small>\n",[42,383,384,408,423,436,450],{"__ignoreMap":218},[369,385,386,390,394,398,401,405],{"class":371,"line":372},[369,387,389],{"class":388},"sVt8B","\u003C",[369,391,393],{"class":392},"s9eBZ","small",[369,395,397],{"class":396},"sScJk"," class",[369,399,400],{"class":388},"=",[369,402,404],{"class":403},"sZZnC","\"mb-2 flex space-x-2 text-sm text-slate-500 dark:text-slate-400\"",[369,406,407],{"class":388},">\n",[369,409,410,413,416,419,421],{"class":371,"line":219},[369,411,412],{"class":388},"  \u003C",[369,414,415],{"class":392},"div",[369,417,418],{"class":388},">Martijn Bos\u003C/",[369,420,415],{"class":392},[369,422,407],{"class":388},[369,424,425,427,429,432,434],{"class":371,"line":220},[369,426,412],{"class":388},[369,428,415],{"class":392},[369,430,431],{"class":388},">•\u003C/",[369,433,415],{"class":392},[369,435,407],{"class":388},[369,437,439,441,443,446,448],{"class":371,"line":438},4,[369,440,412],{"class":388},[369,442,415],{"class":392},[369,444,445],{"class":388},">© {{ new Date().getFullYear() }}\u003C/",[369,447,415],{"class":392},[369,449,407],{"class":388},[369,451,453,456,458],{"class":371,"line":452},5,[369,454,455],{"class":388},"\u003C/",[369,457,393],{"class":392},[369,459,407],{"class":388},[14,461,462],{},"It picked up on the existing Tailwind classes, the dark mode pattern, and even my name from elsewhere in the project. The suggestion was exactly what I would have written myself. I accepted it and moved on.",[24,464,466],{"id":465},"thoughts","Thoughts",[14,468,469],{},"This is a tiny example, obviously. A dynamic copyright year is not a hard problem. But the interesting part was that Copilot did not just generate the JavaScript expression. It produced a complete template block that matched the styling of the rest of the component.",[14,471,472],{},"For small, repetitive tasks like this, it saves a bit of time. Not because the code is hard to write, but because you skip the step of looking up class names and matching the existing patterns.",[14,474,475],{},"I have since used it more, and the results vary. It works well for boilerplate and common patterns. It struggles when the logic gets more specific to your project. The autocomplete style of working, where you accept or reject line by line, also has its limits. But for a first impression, it did exactly what I needed.",[477,478,479],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":218,"searchDepth":219,"depth":220,"links":481},[482,483],{"id":351,"depth":219,"text":352},{"id":465,"depth":219,"text":466},"2023-06-01T00:00:00.000Z","Trying Github Copilot for the first time on a small task and seeing what comment-driven development looks like in practice.","/images/blog/github_copilot_experience.png","Hero image for \"First time using Github Copilot\": Abstract geometric shapes forming collaborative dance, soft glowing particles connecting them, teal","Abstract geometric shapes forming collaborative dance, soft glowing particles connecting them, teal and blue color scheme, modern minimalist aesthetic",{},"/blog/github_copilot_experience",{"title":340,"description":485},"blog/github_copilot_experience",[494,495],"Github Copilot","Code Generation","---4_KOt440XIC1P2GzVZJ6hj-9ryjv9RYffq3F0FrQ",{"id":498,"title":499,"body":500,"created_at":554,"description":555,"extension":237,"image":556,"imageAlt":557,"imagePrompt":558,"meta":559,"navigation":242,"path":560,"published":242,"seo":561,"stem":562,"tags":563,"updated_at":252,"__hash__":565},"blog/blog/claude-code-research-preview.md","Trying Claude Code on the web",{"type":7,"value":501,"toc":548},[502,505,508,512,515,518,522,525,528,532,535,538,542,545],[10,503,499],{"id":504},"trying-claude-code-on-the-web",[14,506,507],{},"Anthropic released a research preview of Claude Code that runs in the browser. I had been using AI coding tools in my editor for a while, so I wanted to see how a web-based approach would feel.",[24,509,511],{"id":510},"the-difference-from-editor-integrations","The difference from editor integrations",[14,513,514],{},"The main thing that sets this apart from something like Copilot is that it works at the project level rather than the file level. It reads your directory structure, understands how files relate to each other, and can make changes across multiple files in one go.",[14,516,517],{},"With Copilot I was always working within a single file, accepting or rejecting line-by-line suggestions. Claude Code operates more like a colleague who has access to your whole repo. You describe what you want, and it figures out which files to touch.",[24,519,521],{"id":520},"what-worked","What worked",[14,523,524],{},"I pointed it at this blog and asked it to make some changes. It picked up on the Nuxt Content structure, the frontmatter format, and the component patterns without me having to explain any of it. That part was genuinely useful.",[14,526,527],{},"The web interface also means there is nothing to install. You connect your repo and start working. For quick tasks on a different machine, that is convenient.",[24,529,531],{"id":530},"what-did-not","What did not",[14,533,534],{},"The context window has limits. On larger codebases, it can lose track of things or miss files that are relevant. You also have to be specific about what you want. Vague instructions lead to vague results, which is true for any AI tool but feels more noticeable when it has access to your entire project.",[14,536,537],{},"Also, I let it write this blog post as an experiment, and the original version was full of generic AI praise. The irony of using an AI tool to write about an AI tool is that it tends to be overly positive about itself. I had to come back and rewrite it later.",[24,539,541],{"id":540},"where-it-fits","Where it fits",[14,543,544],{},"For me, Claude Code on the web works best for quick, scoped tasks: fixing a bug, adding a small feature, or exploring an unfamiliar codebase. For longer development sessions I still prefer working locally with the CLI, where I have more control over the workflow.",[14,546,547],{},"It is a different kind of tool than autocomplete. Whether that is better depends on what you are doing.",{"title":218,"searchDepth":219,"depth":220,"links":549},[550,551,552,553],{"id":510,"depth":219,"text":511},{"id":520,"depth":219,"text":521},{"id":530,"depth":219,"text":531},{"id":540,"depth":219,"text":541},"2025-10-21T00:00:00.000Z","First impressions of the Claude Code web research preview and how it compares to working with AI in a local editor.","/images/blog/claude-code-research-preview.png","Hero image for \"Trying Claude Code on the web\": Translucent browser window floating in cosmic digital space, streams of code and light flowing through","Translucent browser window floating in cosmic digital space, streams of code and light flowing through, deep blue and purple gradient, ethereal glow, minimalist 3D render",{},"/blog/claude-code-research-preview",{"title":499,"description":555},"blog/claude-code-research-preview",[247,248,564],"Development Tools","3VZoMK3GvpdrpCLCIkxa2xgSw6_NCxC-2x82ignv3OY",{"id":567,"title":568,"body":569,"created_at":1512,"description":1513,"extension":237,"image":1514,"imageAlt":1515,"imagePrompt":1516,"meta":1517,"navigation":242,"path":1518,"published":242,"seo":1519,"stem":1520,"tags":1521,"updated_at":252,"__hash__":1523},"blog/blog/ai-powered-blog-images.md","Building an AI-powered image pipeline for my blog with Claude Code",{"type":7,"value":570,"toc":1499},[571,575,578,582,585,589,592,602,608,625,629,633,636,748,755,759,762,928,934,938,945,1092,1096,1099,1113,1116,1120,1123,1367,1370,1387,1397,1401,1462,1465,1469,1475,1481,1487,1496],[10,572,574],{"id":573},"how-this-blog-generates-its-own-images","How this blog generates its own images",[14,576,577],{},"Every post on this blog has a hero image. None of them were made by hand. A GitHub Action generates them automatically whenever a new post is pushed, using AI for both the prompt and the image itself. Here is how it works.",[24,579,581],{"id":580},"the-problem","The problem",[14,583,584],{},"A blog without images looks flat. But manually creating featured images for every post is tedious, and stock photos feel generic. I wanted something that actually reflects the content of each post, without any manual effort.",[24,586,588],{"id":587},"two-layers","Two layers",[14,590,591],{},"The system has two layers, each serving a different purpose.",[14,593,594,597,598,601],{},[307,595,596],{},"Layer 1: Programmatic OG Images."," Using ",[42,599,600],{},"nuxt-og-image"," with Satori, every post gets a text-based social card automatically. These are the fallback. If anything goes wrong with the AI images, social sharing still looks good.",[14,603,604,607],{},[307,605,606],{},"Layer 2: AI-Generated Hero Images."," A TypeScript script generates a unique hero image for each post through two steps: first a text model writes an image prompt based on the post content, then an image model turns that prompt into an actual image.",[14,609,610,611,616,617,620,621,624],{},"Both steps run through ",[31,612,615],{"href":613,"rel":614},"https://openrouter.ai",[35],"OpenRouter",", which provides access to many models through a single API. The current setup uses ",[307,618,619],{},"Gemini 2.5 Flash"," for prompt generation and ",[307,622,623],{},"Gemini 3.1 Flash Image"," for the actual image. Cost is roughly $0.07 per image.",[24,626,628],{"id":627},"how-it-works","How it works",[86,630,632],{"id":631},"the-content-schema","The content schema",[14,634,635],{},"The blog uses Nuxt Content with three image-related fields in the frontmatter:",[361,637,641],{"className":638,"code":639,"language":640,"meta":218,"style":218},"language-typescript shiki shiki-themes github-light github-dark","schema: z.object({\n  published: z.boolean(),\n  created_at: z.date(),\n  tags: z.array(z.string()).optional(),\n  image: z.string().optional(),\n  imageAlt: z.string().optional(),\n  imagePrompt: z.string().optional(),\n})\n","typescript",[42,642,643,657,668,678,700,714,728,742],{"__ignoreMap":218},[369,644,645,648,651,654],{"class":371,"line":372},[369,646,647],{"class":396},"schema",[369,649,650],{"class":388},": z.",[369,652,653],{"class":396},"object",[369,655,656],{"class":388},"({\n",[369,658,659,662,665],{"class":371,"line":219},[369,660,661],{"class":388},"  published: z.",[369,663,664],{"class":396},"boolean",[369,666,667],{"class":388},"(),\n",[369,669,670,673,676],{"class":371,"line":220},[369,671,672],{"class":388},"  created_at: z.",[369,674,675],{"class":396},"date",[369,677,667],{"class":388},[369,679,680,683,686,689,692,695,698],{"class":371,"line":438},[369,681,682],{"class":388},"  tags: z.",[369,684,685],{"class":396},"array",[369,687,688],{"class":388},"(z.",[369,690,691],{"class":396},"string",[369,693,694],{"class":388},"()).",[369,696,697],{"class":396},"optional",[369,699,667],{"class":388},[369,701,702,705,707,710,712],{"class":371,"line":452},[369,703,704],{"class":388},"  image: z.",[369,706,691],{"class":396},[369,708,709],{"class":388},"().",[369,711,697],{"class":396},[369,713,667],{"class":388},[369,715,717,720,722,724,726],{"class":371,"line":716},6,[369,718,719],{"class":388},"  imageAlt: z.",[369,721,691],{"class":396},[369,723,709],{"class":388},[369,725,697],{"class":396},[369,727,667],{"class":388},[369,729,731,734,736,738,740],{"class":371,"line":730},7,[369,732,733],{"class":388},"  imagePrompt: z.",[369,735,691],{"class":396},[369,737,709],{"class":388},[369,739,697],{"class":396},[369,741,667],{"class":388},[369,743,745],{"class":371,"line":744},8,[369,746,747],{"class":388},"})\n",[14,749,750,751,754],{},"The ",[42,752,753],{},"imagePrompt"," field stores the prompt that was used to generate the image. This makes regeneration straightforward and keeps things transparent.",[86,756,758],{"id":757},"prompt-generation","Prompt generation",[14,760,761],{},"A system prompt defines the visual style for the blog: abstract, modern, deep blues and purples, no literal depictions of laptops or code screens. The text model receives the blog post content along with this system prompt and returns a short image prompt (under 200 characters).",[361,763,765],{"className":638,"code":764,"language":640,"meta":218,"style":218},"const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${OPENROUTER_API_KEY}`,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({\n    model: 'google/gemini-2.5-flash',\n    messages: [\n      { role: 'system', content: systemPrompt },\n      { role: 'user', content: userPrompt },\n    ],\n    max_tokens: 200,\n  }),\n})\n",[42,766,767,795,806,811,827,840,845,861,871,877,889,900,906,917,923],{"__ignoreMap":218},[369,768,769,773,777,780,783,786,789,792],{"class":371,"line":372},[369,770,772],{"class":771},"szBVR","const",[369,774,776],{"class":775},"sj4cs"," response",[369,778,779],{"class":771}," =",[369,781,782],{"class":771}," await",[369,784,785],{"class":396}," fetch",[369,787,788],{"class":388},"(",[369,790,791],{"class":403},"'https://openrouter.ai/api/v1/chat/completions'",[369,793,794],{"class":388},", {\n",[369,796,797,800,803],{"class":371,"line":219},[369,798,799],{"class":388},"  method: ",[369,801,802],{"class":403},"'POST'",[369,804,805],{"class":388},",\n",[369,807,808],{"class":371,"line":220},[369,809,810],{"class":388},"  headers: {\n",[369,812,813,816,819,822,825],{"class":371,"line":438},[369,814,815],{"class":388},"    Authorization: ",[369,817,818],{"class":403},"`Bearer ${",[369,820,821],{"class":775},"OPENROUTER_API_KEY",[369,823,824],{"class":403},"}`",[369,826,805],{"class":388},[369,828,829,832,835,838],{"class":371,"line":452},[369,830,831],{"class":403},"    'Content-Type'",[369,833,834],{"class":388},": ",[369,836,837],{"class":403},"'application/json'",[369,839,805],{"class":388},[369,841,842],{"class":371,"line":716},[369,843,844],{"class":388},"  },\n",[369,846,847,850,853,856,859],{"class":371,"line":730},[369,848,849],{"class":388},"  body: ",[369,851,852],{"class":775},"JSON",[369,854,855],{"class":388},".",[369,857,858],{"class":396},"stringify",[369,860,656],{"class":388},[369,862,863,866,869],{"class":371,"line":744},[369,864,865],{"class":388},"    model: ",[369,867,868],{"class":403},"'google/gemini-2.5-flash'",[369,870,805],{"class":388},[369,872,874],{"class":371,"line":873},9,[369,875,876],{"class":388},"    messages: [\n",[369,878,880,883,886],{"class":371,"line":879},10,[369,881,882],{"class":388},"      { role: ",[369,884,885],{"class":403},"'system'",[369,887,888],{"class":388},", content: systemPrompt },\n",[369,890,892,894,897],{"class":371,"line":891},11,[369,893,882],{"class":388},[369,895,896],{"class":403},"'user'",[369,898,899],{"class":388},", content: userPrompt },\n",[369,901,903],{"class":371,"line":902},12,[369,904,905],{"class":388},"    ],\n",[369,907,909,912,915],{"class":371,"line":908},13,[369,910,911],{"class":388},"    max_tokens: ",[369,913,914],{"class":775},"200",[369,916,805],{"class":388},[369,918,920],{"class":371,"line":919},14,[369,921,922],{"class":388},"  }),\n",[369,924,926],{"class":371,"line":925},15,[369,927,747],{"class":388},[14,929,930,931,933],{},"If a post already has an ",[42,932,753],{}," in its frontmatter, the script skips this step and reuses the existing prompt.",[86,935,937],{"id":936},"image-generation","Image generation",[14,939,940,941,944],{},"The image model receives the prompt and returns a base64-encoded image, which gets saved to ",[42,942,943],{},"public/images/blog/",". The frontmatter is then updated with the image path, alt text, and the prompt that was used.",[361,946,948],{"className":638,"code":947,"language":640,"meta":218,"style":218},"const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${OPENROUTER_API_KEY}`,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({\n    model: 'google/gemini-3.1-flash-image-preview',\n    modalities: ['image', 'text'],\n    messages: [{\n      role: 'user',\n      content: [{ type: 'text', text: `Generate a 1200x630 blog hero image: ${prompt}` }],\n    }],\n  }),\n})\n",[42,949,950,968,976,980,992,1002,1006,1018,1027,1044,1049,1058,1079,1084,1088],{"__ignoreMap":218},[369,951,952,954,956,958,960,962,964,966],{"class":371,"line":372},[369,953,772],{"class":771},[369,955,776],{"class":775},[369,957,779],{"class":771},[369,959,782],{"class":771},[369,961,785],{"class":396},[369,963,788],{"class":388},[369,965,791],{"class":403},[369,967,794],{"class":388},[369,969,970,972,974],{"class":371,"line":219},[369,971,799],{"class":388},[369,973,802],{"class":403},[369,975,805],{"class":388},[369,977,978],{"class":371,"line":220},[369,979,810],{"class":388},[369,981,982,984,986,988,990],{"class":371,"line":438},[369,983,815],{"class":388},[369,985,818],{"class":403},[369,987,821],{"class":775},[369,989,824],{"class":403},[369,991,805],{"class":388},[369,993,994,996,998,1000],{"class":371,"line":452},[369,995,831],{"class":403},[369,997,834],{"class":388},[369,999,837],{"class":403},[369,1001,805],{"class":388},[369,1003,1004],{"class":371,"line":716},[369,1005,844],{"class":388},[369,1007,1008,1010,1012,1014,1016],{"class":371,"line":730},[369,1009,849],{"class":388},[369,1011,852],{"class":775},[369,1013,855],{"class":388},[369,1015,858],{"class":396},[369,1017,656],{"class":388},[369,1019,1020,1022,1025],{"class":371,"line":744},[369,1021,865],{"class":388},[369,1023,1024],{"class":403},"'google/gemini-3.1-flash-image-preview'",[369,1026,805],{"class":388},[369,1028,1029,1032,1035,1038,1041],{"class":371,"line":873},[369,1030,1031],{"class":388},"    modalities: [",[369,1033,1034],{"class":403},"'image'",[369,1036,1037],{"class":388},", ",[369,1039,1040],{"class":403},"'text'",[369,1042,1043],{"class":388},"],\n",[369,1045,1046],{"class":371,"line":879},[369,1047,1048],{"class":388},"    messages: [{\n",[369,1050,1051,1054,1056],{"class":371,"line":891},[369,1052,1053],{"class":388},"      role: ",[369,1055,896],{"class":403},[369,1057,805],{"class":388},[369,1059,1060,1063,1065,1068,1071,1074,1076],{"class":371,"line":902},[369,1061,1062],{"class":388},"      content: [{ type: ",[369,1064,1040],{"class":403},[369,1066,1067],{"class":388},", text: ",[369,1069,1070],{"class":403},"`Generate a 1200x630 blog hero image: ${",[369,1072,1073],{"class":388},"prompt",[369,1075,824],{"class":403},[369,1077,1078],{"class":388}," }],\n",[369,1080,1081],{"class":371,"line":908},[369,1082,1083],{"class":388},"    }],\n",[369,1085,1086],{"class":371,"line":919},[369,1087,922],{"class":388},[369,1089,1090],{"class":371,"line":925},[369,1091,747],{"class":388},[86,1093,1095],{"id":1094},"the-style-guide","The style guide",[14,1097,1098],{},"The quality of the images depends on the system prompt. Here is the gist of what works well:",[190,1100,1101,1104,1107,1110],{},[193,1102,1103],{},"Abstract geometric patterns, flowing light trails, data streams",[193,1105,1106],{},"Deep blues, purples, and teals with accent colors",[193,1108,1109],{},"Enough negative space for text overlay",[193,1111,1112],{},"No literal depictions, no stock photo cliches, no faces",[14,1114,1115],{},"Each image ends up feeling like it belongs to the same blog while still being distinct.",[24,1117,1119],{"id":1118},"the-pipeline","The pipeline",[14,1121,1122],{},"Everything runs in a GitHub Action. No local tooling required.",[361,1124,1128],{"className":1125,"code":1126,"language":1127,"meta":218,"style":218},"language-yaml shiki shiki-themes github-light github-dark","name: Generate Blog Images\n\non:\n  push:\n    branches: [main]\n    paths:\n      - 'content/blog/**'\n  workflow_dispatch: {}\n\njobs:\n  generate-images:\n    runs-on: ubuntu-latest\n    if: github.actor != 'github-actions[bot]'\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v4\n      - run: pnpm install --frozen-lockfile\n      - name: Generate missing blog images\n        env:\n          OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}\n        run: npx tsx scripts/generate-blog-images.ts\n      - name: Commit generated images\n        run: |\n          git add public/images/blog/ content/blog/\n          git diff --cached --quiet || git commit -m \"Generate blog hero images\" && git push\n","yaml",[42,1129,1130,1140,1145,1153,1160,1174,1181,1189,1197,1201,1208,1215,1225,1235,1242,1254,1266,1278,1291,1303,1311,1322,1333,1345,1355,1361],{"__ignoreMap":218},[369,1131,1132,1135,1137],{"class":371,"line":372},[369,1133,1134],{"class":392},"name",[369,1136,834],{"class":388},[369,1138,1139],{"class":403},"Generate Blog Images\n",[369,1141,1142],{"class":371,"line":219},[369,1143,1144],{"emptyLinePlaceholder":242},"\n",[369,1146,1147,1150],{"class":371,"line":220},[369,1148,1149],{"class":775},"on",[369,1151,1152],{"class":388},":\n",[369,1154,1155,1158],{"class":371,"line":438},[369,1156,1157],{"class":392},"  push",[369,1159,1152],{"class":388},[369,1161,1162,1165,1168,1171],{"class":371,"line":452},[369,1163,1164],{"class":392},"    branches",[369,1166,1167],{"class":388},": [",[369,1169,1170],{"class":403},"main",[369,1172,1173],{"class":388},"]\n",[369,1175,1176,1179],{"class":371,"line":716},[369,1177,1178],{"class":392},"    paths",[369,1180,1152],{"class":388},[369,1182,1183,1186],{"class":371,"line":730},[369,1184,1185],{"class":388},"      - ",[369,1187,1188],{"class":403},"'content/blog/**'\n",[369,1190,1191,1194],{"class":371,"line":744},[369,1192,1193],{"class":392},"  workflow_dispatch",[369,1195,1196],{"class":388},": {}\n",[369,1198,1199],{"class":371,"line":873},[369,1200,1144],{"emptyLinePlaceholder":242},[369,1202,1203,1206],{"class":371,"line":879},[369,1204,1205],{"class":392},"jobs",[369,1207,1152],{"class":388},[369,1209,1210,1213],{"class":371,"line":891},[369,1211,1212],{"class":392},"  generate-images",[369,1214,1152],{"class":388},[369,1216,1217,1220,1222],{"class":371,"line":902},[369,1218,1219],{"class":392},"    runs-on",[369,1221,834],{"class":388},[369,1223,1224],{"class":403},"ubuntu-latest\n",[369,1226,1227,1230,1232],{"class":371,"line":908},[369,1228,1229],{"class":392},"    if",[369,1231,834],{"class":388},[369,1233,1234],{"class":403},"github.actor != 'github-actions[bot]'\n",[369,1236,1237,1240],{"class":371,"line":919},[369,1238,1239],{"class":392},"    steps",[369,1241,1152],{"class":388},[369,1243,1244,1246,1249,1251],{"class":371,"line":925},[369,1245,1185],{"class":388},[369,1247,1248],{"class":392},"uses",[369,1250,834],{"class":388},[369,1252,1253],{"class":403},"actions/checkout@v4\n",[369,1255,1257,1259,1261,1263],{"class":371,"line":1256},16,[369,1258,1185],{"class":388},[369,1260,1248],{"class":392},[369,1262,834],{"class":388},[369,1264,1265],{"class":403},"pnpm/action-setup@v4\n",[369,1267,1269,1271,1273,1275],{"class":371,"line":1268},17,[369,1270,1185],{"class":388},[369,1272,1248],{"class":392},[369,1274,834],{"class":388},[369,1276,1277],{"class":403},"actions/setup-node@v4\n",[369,1279,1281,1283,1286,1288],{"class":371,"line":1280},18,[369,1282,1185],{"class":388},[369,1284,1285],{"class":392},"run",[369,1287,834],{"class":388},[369,1289,1290],{"class":403},"pnpm install --frozen-lockfile\n",[369,1292,1294,1296,1298,1300],{"class":371,"line":1293},19,[369,1295,1185],{"class":388},[369,1297,1134],{"class":392},[369,1299,834],{"class":388},[369,1301,1302],{"class":403},"Generate missing blog images\n",[369,1304,1306,1309],{"class":371,"line":1305},20,[369,1307,1308],{"class":392},"        env",[369,1310,1152],{"class":388},[369,1312,1314,1317,1319],{"class":371,"line":1313},21,[369,1315,1316],{"class":392},"          OPENROUTER_API_KEY",[369,1318,834],{"class":388},[369,1320,1321],{"class":403},"${{ secrets.OPENROUTER_API_KEY }}\n",[369,1323,1325,1328,1330],{"class":371,"line":1324},22,[369,1326,1327],{"class":392},"        run",[369,1329,834],{"class":388},[369,1331,1332],{"class":403},"npx tsx scripts/generate-blog-images.ts\n",[369,1334,1336,1338,1340,1342],{"class":371,"line":1335},23,[369,1337,1185],{"class":388},[369,1339,1134],{"class":392},[369,1341,834],{"class":388},[369,1343,1344],{"class":403},"Commit generated images\n",[369,1346,1348,1350,1352],{"class":371,"line":1347},24,[369,1349,1327],{"class":392},[369,1351,834],{"class":388},[369,1353,1354],{"class":771},"|\n",[369,1356,1358],{"class":371,"line":1357},25,[369,1359,1360],{"class":403},"          git add public/images/blog/ content/blog/\n",[369,1362,1364],{"class":371,"line":1363},26,[369,1365,1366],{"class":403},"          git diff --cached --quiet || git commit -m \"Generate blog hero images\" && git push\n",[14,1368,1369],{},"The flow:",[1371,1372,1373,1378,1381,1384],"ol",{},[193,1374,1375,1376],{},"Push a new blog post to ",[42,1377,1170],{},[193,1379,1380],{},"The action detects the change, generates the prompt and image",[193,1382,1383],{},"It commits the image and updated frontmatter back to the repo",[193,1385,1386],{},"Cloudflare Pages picks up the new commit and deploys",[14,1388,750,1389,1392,1393,1396],{},[42,1390,1391],{},"github.actor"," check prevents the action from running on its own commits, avoiding an infinite loop. Posts that already have an ",[42,1394,1395],{},"image"," field are skipped, so each image is only generated once.",[24,1398,1400],{"id":1399},"the-result","The result",[1402,1403,1404,1417],"table",{},[1405,1406,1407],"thead",{},[1408,1409,1410,1414],"tr",{},[1411,1412,1413],"th",{},"Scenario",[1411,1415,1416],{},"What Happens",[1418,1419,1420,1431,1441,1451],"tbody",{},[1408,1421,1422,1428],{},[1423,1424,1425,1426],"td",{},"New post, no ",[42,1427,753],{},[1423,1429,1430],{},"Text model generates a prompt, image model generates the image",[1408,1432,1433,1438],{},[1423,1434,1435,1436],{},"New post, has ",[42,1437,753],{},[1423,1439,1440],{},"Skips prompt generation, generates the image from the existing prompt",[1408,1442,1443,1448],{},[1423,1444,1445,1446],{},"Existing post with ",[42,1447,1395],{},[1423,1449,1450],{},"Skipped entirely",[1408,1452,1453,1456],{},[1423,1454,1455],{},"Need to regenerate",[1423,1457,1458,1459,1461],{},"Remove the ",[42,1460,1395],{}," field from frontmatter, push",[14,1463,1464],{},"The whole pipeline runs in about 30 seconds per image. For a blog that publishes occasionally, the cost is negligible.",[24,1466,1468],{"id":1467},"takeaways","Takeaways",[14,1470,1471,1474],{},[307,1472,1473],{},"Store your prompts."," Keeping the prompt in frontmatter means you can always see what generated an image, tweak it, and regenerate. It also means the image model step can run independently from the prompt generation step.",[14,1476,1477,1480],{},[307,1478,1479],{},"Separate prompt generation from image generation."," Text models are good at understanding content and writing prompts. Image models are good at generating visuals. Letting each do what it is best at produces better results than trying to do everything in one step.",[14,1482,1483,1486],{},[307,1484,1485],{},"Always have a fallback."," The site works fine without hero images. The programmatic OG images ensure social sharing always looks professional, even if the AI pipeline has a bad day.",[14,1488,1489,1492,1493,1495],{},[307,1490,1491],{},"Keep it boring in CI."," The script checks for an existing ",[42,1494,1395],{}," field before doing anything. No unnecessary API calls, no surprise costs. Write a post, push, and forget about it.",[477,1497,1498],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":218,"searchDepth":219,"depth":220,"links":1500},[1501,1502,1503,1509,1510,1511],{"id":580,"depth":219,"text":581},{"id":587,"depth":219,"text":588},{"id":627,"depth":219,"text":628,"children":1504},[1505,1506,1507,1508],{"id":631,"depth":220,"text":632},{"id":757,"depth":220,"text":758},{"id":936,"depth":220,"text":937},{"id":1094,"depth":220,"text":1095},{"id":1118,"depth":219,"text":1119},{"id":1399,"depth":219,"text":1400},{"id":1467,"depth":219,"text":1468},"2026-01-07T00:00:00.000Z","How I used Claude Code on the web to design and implement automatic AI-generated featured images for my Nuxt blog, complete with Cloudflare Pages integration.","/images/blog/ai-powered-blog-images.png","Hero image for \"Building an AI-powered image pipeline for my blog with Claude Code\": Abstract digital assembly line with glowing nodes transforming text into vibrant images, streams of","Abstract digital assembly line with glowing nodes transforming text into vibrant images, streams of data flowing through crystalline pipelines, deep blue and magenta gradient, futuristic minimal aesthetic",{},"/blog/ai-powered-blog-images",{"title":568,"description":1513},"blog/ai-powered-blog-images",[247,248,274,249,1522],"Cloudflare","WUDCPMv0Atwn3jzHpQkx-AZFEEOxOs97KPR__09fUdU",{"id":1525,"title":1526,"body":1527,"created_at":1668,"description":1669,"extension":237,"image":1670,"imageAlt":1671,"imagePrompt":1672,"meta":1673,"navigation":242,"path":1674,"published":242,"seo":1675,"stem":1676,"tags":1677,"updated_at":252,"__hash__":1679},"blog/blog/my-current-claude-code-setup.md","My current Claude Code setup and how I use it",{"type":7,"value":1528,"toc":1662},[1529,1532,1535,1538,1542,1545,1561,1564,1568,1575,1578,1583,1586,1612,1615,1619,1626,1629,1632,1635,1639,1659],[10,1530,1526],{"id":1531},"my-current-claude-code-setup-and-how-i-use-it",[14,1533,1534],{},"Claude Code is my daily driver. Making use of the Max plan, I enjoy writing code and am doing it faster than ever before.",[14,1536,1537],{},"While everybody can use Claude Code and produce code with it, I have noticed that the quality of your input and the way you use the tool dictate whether the output is actually scalable. Smaller projects can be vibe-coded with ease by anyone. But once you work on something larger, a skilled and structural approach is what makes LLMs truly useful.",[24,1539,1541],{"id":1540},"planning-matters-but-not-blindly","Planning matters, but not blindly",[14,1543,1544],{},"Planning is important. That said, I have found that the built-in planning mode of Claude makes a lot of mistakes when you blindly accept its suggestions. It tends to spit out a plan early, and if you just go with it, you often end up course-correcting halfway through.",[14,1546,1547,1548,1551,1552,1557,1558,1560],{},"Recently I came across the ",[42,1549,1550],{},"/grill-me"," skill by ",[31,1553,1556],{"href":1554,"rel":1555},"https://github.com/mattpocock/skills",[35],"Matt Pocock",". This one changed how I approach planning entirely. Instead of letting Claude draft a plan and nodding along, ",[42,1559,1550],{}," flips the dynamic. It interviews you relentlessly about every aspect of your plan, walking through each branch of the design tree and resolving dependencies between decisions one by one. For each question, it provides a recommended answer, and if something can be answered by exploring the codebase, it does that instead of asking you.",[14,1562,1563],{},"I fired it off yesterday and went 33 questions deep to get a thorough co-understanding of the feature I had to build. By the end of it, both Claude and I were fully aligned on what needed to happen, with no ambiguity left. I encourage you to try it.",[24,1565,1567],{"id":1566},"test-driven-development-that-actually-works","Test-driven development that actually works",[14,1569,1570,1571,1574],{},"Matt has more skills published, and ",[42,1572,1573],{},"/tdd"," is another one I reach for regularly.",[14,1576,1577],{},"LLMs often mess up writing tests. The typical failure mode is that they look at the existing code and write tests that satisfy the current implementation rather than testing what the code should actually do. The tests pass, but they are brittle and coupled to internals. Refactor something and they break, even when the behavior has not changed.",[14,1579,750,1580,1582],{},[42,1581,1573],{}," skill tackles this by enforcing a vertical slice approach. Instead of writing all tests first and then all implementation (horizontal slicing), it works in small cycles: write one test, write just enough code to make it pass, then move on to the next behavior. Each cycle builds on what was learned from the previous one.",[14,1584,1585],{},"The flow looks like this:",[1371,1587,1588,1594,1600,1606],{},[193,1589,1590,1593],{},[307,1591,1592],{},"Plan"," the behaviors to test and confirm the interface",[193,1595,1596,1599],{},[307,1597,1598],{},"Tracer bullet"," -- write a single test for the first behavior (red), then minimal code to pass it (green)",[193,1601,1602,1605],{},[307,1603,1604],{},"Incremental loop"," -- repeat for each remaining behavior, one test at a time",[193,1607,1608,1611],{},[307,1609,1610],{},"Refactor"," -- only after all tests pass, extract duplication and clean up. Never refactor while red.",[14,1613,1614],{},"Each test should describe behavior, use the public interface only, and survive internal refactoring. This forces both you and the LLM to think about what the code should do rather than how it currently does it.",[24,1616,1618],{"id":1617},"exploring-architectural-improvements","Exploring architectural improvements",[14,1620,1621,1622,1625],{},"I have also been using the ",[42,1623,1624],{},"/improve-codebase-architecture"," skill. This one takes a different angle. Instead of working on a specific feature, it explores your codebase looking for architectural improvement opportunities.",[14,1627,1628],{},"The core idea is borrowed from John Ousterhout's \"A Philosophy of Software Design.\" It looks for shallow modules -- components where the interface is nearly as complex as the implementation -- and identifies opportunities to deepen them. Deep modules have simple interfaces that hide complex implementations, which makes them more testable and easier to work with.",[14,1630,1631],{},"The skill walks through a multi-step process. It starts by organically exploring your codebase, looking for friction points: concepts scattered across many files, tightly coupled modules, functions extracted purely for testability, or components that are hard to test. It then presents a list of candidates, and once you pick one, it spins up multiple sub-agents to generate radically different interface designs. You pick the one that fits best, and it files a refactor RFC as a GitHub issue.",[14,1633,1634],{},"What I like about this approach is that it does not just point out problems. It gives you concrete options and lets you decide what to act on.",[24,1636,1638],{"id":1637},"what-else-is-out-there","What else is out there?",[14,1640,1641,1642,1646,1647,1650,1651,1654,1655,1658],{},"Matt's ",[31,1643,1645],{"href":1554,"rel":1644},[35],"skill repository"," has even more options. There is ",[42,1648,1649],{},"/write-a-prd"," for creating product requirement documents through an interactive interview, ",[42,1652,1653],{},"/prd-to-plan"," for converting those into phased implementation strategies, ",[42,1656,1657],{},"/request-refactor-plan"," for creating detailed refactor plans with small commits, and several others.",[14,1660,1661],{},"I am curious to explore more of these. If you come across any interesting skills or have built your own, let me know.",{"title":218,"searchDepth":219,"depth":220,"links":1663},[1664,1665,1666,1667],{"id":1540,"depth":219,"text":1541},{"id":1566,"depth":219,"text":1567},{"id":1617,"depth":219,"text":1618},{"id":1637,"depth":219,"text":1638},"2026-04-03T00:00:00.000Z","How I get the most out of Claude Code with the Max plan, and why skills like grill-me, tdd, and improve-codebase-architecture changed the way I work with LLMs.","/images/blog/my-current-claude-code-setup.png","Hero image for \"My current Claude Code setup and how I use it\": Abstract workspace with layered translucent panels showing code fragments and question marks morphin","Abstract workspace with layered translucent panels showing code fragments and question marks morphing into structured blueprints, flowing teal and indigo gradients, geometric minimalist aesthetic",{},"/blog/my-current-claude-code-setup",{"title":1526,"description":1669},"blog/my-current-claude-code-setup",[247,248,564,1678],"Productivity","LH7ksaC8y7cRT_apXpPmPIg7yG3D7Ih-TjjCE4rDobs",{"id":4,"title":5,"body":1681,"created_at":235,"description":236,"extension":237,"image":238,"imageAlt":239,"imagePrompt":240,"meta":1819,"navigation":242,"path":243,"published":242,"seo":1820,"stem":245,"tags":1821,"updated_at":252,"__hash__":253},{"type":7,"value":1682,"toc":1804},[1683,1685,1687,1689,1691,1693,1698,1704,1706,1708,1710,1712,1718,1720,1722,1724,1726,1728,1730,1732,1734,1736,1738,1740,1742,1744,1746,1748,1750,1752,1754,1756,1758,1760,1762,1764,1768,1772,1774,1776,1778,1780,1782,1784,1786,1802],[10,1684,5],{"id":12},[14,1686,16],{},[14,1688,19],{},[14,1690,22],{},[24,1692,27],{"id":26},[14,1694,1695,37],{},[31,1696,36],{"href":33,"rel":1697},[35],[14,1699,40,1700,45,1702,49],{},[42,1701,44],{},[42,1703,48],{},[24,1705,53],{"id":52},[14,1707,56],{},[14,1709,59],{},[24,1711,63],{"id":62},[14,1713,66,1714,70,1716,74],{},[42,1715,69],{},[42,1717,73],{},[14,1719,77],{},[24,1721,81],{"id":80},[14,1723,84],{},[86,1725,89],{"id":88},[14,1727,92],{},[14,1729,95],{},[14,1731,98],{},[14,1733,101],{},[86,1735,105],{"id":104},[14,1737,108],{},[14,1739,111],{},[14,1741,114],{},[14,1743,117],{},[86,1745,121],{"id":120},[14,1747,124],{},[14,1749,127],{},[14,1751,130],{},[86,1753,134],{"id":133},[14,1755,137],{},[14,1757,140],{},[14,1759,143],{},[14,1761,146],{},[24,1763,150],{"id":149},[14,1765,153,1766,157],{},[42,1767,156],{},[14,1769,160,1770,164],{},[42,1771,163],{},[24,1773,168],{"id":167},[14,1775,171],{},[14,1777,174],{},[24,1779,178],{"id":177},[14,1781,181],{},[24,1783,185],{"id":184},[14,1785,188],{},[190,1787,1788,1790,1792,1794,1796,1798,1800],{},[193,1789,195],{},[193,1791,198],{},[193,1793,201],{},[193,1795,204],{},[193,1797,207],{},[193,1799,210],{},[193,1801,213],{},[14,1803,216],{},{"title":218,"searchDepth":219,"depth":220,"links":1805},[1806,1807,1808,1809,1815,1816,1817,1818],{"id":26,"depth":219,"text":27},{"id":52,"depth":219,"text":53},{"id":62,"depth":219,"text":63},{"id":80,"depth":219,"text":81,"children":1810},[1811,1812,1813,1814],{"id":88,"depth":220,"text":89},{"id":104,"depth":220,"text":105},{"id":120,"depth":220,"text":121},{"id":133,"depth":220,"text":134},{"id":149,"depth":219,"text":150},{"id":167,"depth":219,"text":168},{"id":177,"depth":219,"text":178},{"id":184,"depth":219,"text":185},{},{"title":5,"description":236},[247,248,249,250,251],1783085992607]