[{"content":" Designed and built a distributed system that detects unbilled usage across all AWS services — reducing charge discrepancies by **300x** and eliminating **230 million** monthly false positives. Key Metrics # 300x Reduction in Discrepancies $125,000 → $432 230M False Positives Eliminated ~95% Alert Actionability Architecture # flowchart LR A[\"Usage Records\\n(Billions/day)\"] --\u003e B[\"Smart Sampling\\n\u0026 Aggregation\"] B --\u003e C[\"Multi-Signal\\nValidation\"] C --\u003e D[\"Automated\\nResolution\"] D --\u003e E{Real issue?} E -- Yes --\u003e F[\"Alert with\\nDiagnosis\"] E -- No --\u003e G[\"Auto-resolve\\n\u0026 Log\"] style B fill:#6366f1,color:#fff style C fill:#6366f1,color:#fff style D fill:#6366f1,color:#fff Technical Deep Dive # Aggregation over Brute-Force # Instead of checking every individual usage record (which generated 230M false positives), the system aggregates at the service-account-period level.\nBuilt on DynamoDB for consistent low-latency reads at any scale Each record stores expected charge, actual charge, pricing plan, and discount metadata Reduced comparison space by orders of magnitude while preserving detection capability Beyond Simple Mismatch Detection # A single charge mismatch doesn\u0026rsquo;t indicate a problem. The validation pipeline (built on AWS Lambda) checks multiple signals:\nTemporal correlation — Is this a timing issue that self-corrects? Pricing context — Did a pricing change or discount explain the difference? Historical pattern — Has this account shown similar patterns before? Magnitude thresholds — Is the discrepancy large enough to investigate? Only records failing all validation checks are escalated.\nFrom Symptom to Diagnosis # Common discrepancy patterns trigger automated remediation:\nRe-processing dropped usage records Applying missing discounts retroactively Flagging records for manual review with specific context about what went wrong Engineers receive alerts with a diagnosis, not just a symptom.\nImpact # Tech Stack # Java DynamoDB AWS Lambda Distributed Systems Billing Pipeline Read the Full Story ","externalUrl":null,"permalink":"/projects/aws-billing-auditor/","section":"Projects","summary":" Designed and built a distributed system that detects unbilled usage across all AWS services — reducing charge discrepancies by **300x** and eliminating **230 million** monthly false positives. Key Metrics # 300x Reduction in Discrepancies $125,000 → $432 230M False Positives Eliminated ~95% Alert Actionability Architecture # flowchart LR A[\"Usage Records\\n(Billions/day)\"] --\u003e B[\"Smart Sampling\\n\u0026 Aggregation\"] B --\u003e C[\"Multi-Signal\\nValidation\"] C --\u003e D[\"Automated\\nResolution\"] D --\u003e E{Real issue?} E -- Yes --\u003e F[\"Alert with\\nDiagnosis\"] E -- No --\u003e G[\"Auto-resolve\\n\u0026 Log\"] style B fill:#6366f1,color:#fff style C fill:#6366f1,color:#fff style D fill:#6366f1,color:#fff Technical Deep Dive # Aggregation over Brute-Force # Instead of checking every individual usage record (which generated 230M false positives), the system aggregates at the service-account-period level.\n","title":"AWS Billing Unbilled Usage Auditor","type":"projects"},{"content":" Built the automation and validation tooling to manage kernel updates across **5,000+ production servers** at Twitter — with zero-downtime progressive rollouts and automated canary validation. Key Metrics # 5,000+ Production Hosts Weeks → Days Rollout Time 140+ Tickets in One On-Call Week Zero-Downtime Updates Architecture # flowchart LR A[\"New Kernel\\nVersion\"] --\u003e B[\"Canary\\nValidation\"] B --\u003e C[\"Wave 1\\n1% Fleet\"] C --\u003e D[\"Wave 2\\n5% Fleet\"] D --\u003e E[\"Wave 3\\n25% Fleet\"] E --\u003e F[\"Full Fleet\\nRollout\"] C -- anomaly --\u003e G[\"Pause \u0026\\nAuto-Alert\"] D -- anomaly --\u003e G E -- anomaly --\u003e G style B fill:#6366f1,color:#fff style C fill:#6366f1,color:#fff style D fill:#6366f1,color:#fff style E fill:#6366f1,color:#fff style F fill:#6366f1,color:#fff Technical Deep Dive # Validate Before You Roll # A Python library that validates kernel safety before fleet-wide rollout:\nProvisions canary hosts from each hardware/workload combination Applies kernel update and reboots canary hosts Runs validation suites — system stability, performance benchmarks, application health Compares baselines — CPU, memory, I/O latency, network throughput vs. production Only after passing canary validation on every host type is a kernel approved for rollout.\nWave-Based Deployment # Automated deployment in progressive waves with anomaly detection between each:\nWave Coverage Purpose Wave 1 1% Smoke test in production Wave 2 5% Expand to more host types Wave 3 25% Majority coverage Wave 4 100% Full fleet completion Between each wave: automated monitoring for unexpected reboots, performance regression, and application errors. Any signal crossing a threshold pauses the rollout automatically.\nEliminating Drift # Configuration drift was the root cause of most fleet management pain. Built tooling to:\nAudit every host against its declared state Detect drift from intended configuration Auto-remediate safe divergences, flag risky ones for human review This was a prerequisite for safe automation — you can\u0026rsquo;t automate kernel updates on hosts whose configuration you don\u0026rsquo;t fully understand.\nRuntime Cache Control # Custom commands system for Twitter\u0026rsquo;s Redis-based cache services using Go:\nInspect and modify cache behavior at runtime Debug production issues without service restarts Zero customer impact during investigation Impact # Tech Stack # Python Go Redis Linux Kernel Fleet Management Bare Metal Read the Full Story ","externalUrl":null,"permalink":"/projects/twitter-fleet-automation/","section":"Projects","summary":" Built the automation and validation tooling to manage kernel updates across **5,000+ production servers** at Twitter — with zero-downtime progressive rollouts and automated canary validation. Key Metrics # 5,000+ Production Hosts Weeks → Days Rollout Time 140+ Tickets in One On-Call Week Zero-Downtime Updates Architecture # flowchart LR A[\"New Kernel\\nVersion\"] --\u003e B[\"Canary\\nValidation\"] B --\u003e C[\"Wave 1\\n1% Fleet\"] C --\u003e D[\"Wave 2\\n5% Fleet\"] D --\u003e E[\"Wave 3\\n25% Fleet\"] E --\u003e F[\"Full Fleet\\nRollout\"] C -- anomaly --\u003e G[\"Pause \u0026\\nAuto-Alert\"] D -- anomaly --\u003e G E -- anomaly --\u003e G style B fill:#6366f1,color:#fff style C fill:#6366f1,color:#fff style D fill:#6366f1,color:#fff style E fill:#6366f1,color:#fff style F fill:#6366f1,color:#fff Technical Deep Dive # Validate Before You Roll # A Python library that validates kernel safety before fleet-wide rollout:\n","title":"Twitter Fleet-Scale Kernel Automation","type":"projects"},{"content":"","date":"15 July 2026","externalUrl":null,"permalink":"/categories/ai--productivity/","section":"Categories","summary":"","title":"AI \u0026 Productivity","type":"categories"},{"content":"","date":"15 July 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"15 July 2026","externalUrl":null,"permalink":"/tags/claude-code/","section":"Tags","summary":"","title":"Claude-Code","type":"tags"},{"content":"","date":"15 July 2026","externalUrl":null,"permalink":"/tags/hooks/","section":"Tags","summary":"","title":"Hooks","type":"tags"},{"content":" My Claude Code config now holds two hooks, ten skills, and three custom subagents, and most of them started life in the wrong layer. The instruction the model followed nine times out of ten lived in a prompt until I accepted that nine out of ten is a coin I lose every day. The workflow I pasted into chats became a skill. The bulk work that was draining my priciest model's quota became a fleet of cheaper agents. Same features, different failure modes. Taming Claude Code Sessions · Part 6 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS The four layers # Claude Code has four extension points, and they answer four different questions:\nLayer Question it answers Who decides it runs CLAUDE.md What should the model always know here? Nobody; it is always in context Hook What must happen every time, no exceptions? Your shell script, deterministically Skill How do we do X around here? The model, when it judges X is relevant Subagent What deserves its own context, tools, or model? The model delegates, or you ask it to The column that matters is the last one. Every misplacement I have made comes down to ignoring who actually decides whether the thing runs.\nHooks: when it must happen every time # A hook is a script that fires on an event: session start, before a tool call, after one. No model judgment anywhere in the path. That makes it the only layer that can make promises.\nMy test case was session naming. Part 2 of this series covers the hook itself; the short version is that I wanted every session titled by project and branch so I could find it again. As a habit, it decayed. As a prompt instruction, it worked most of the time, and \u0026ldquo;most of the time\u0026rdquo; is exactly the property that makes a pile of unnamed sessions. As a SessionStart hook, it has run on every session since, including the ones launched over SSH and from tools that never show me a prompt at all.\nThe rule that fell out of this: if you would be upset by one missed execution, it cannot rely on model judgment. Naming, notifications, permission gates, lint-before-commit. Hook territory, all of it.\nSkills: when it is knowledge, not a guarantee # A skill is a folder of instructions the model loads when the task looks relevant. It is the right home for how-to knowledge that would bloat CLAUDE.md if it sat in context permanently.\nThe clearest example in my setup is this blog. Writing conventions, shortcode rules, a sourcing policy, a pre-publish checklist: around two hundred lines. They matter only when writing a post, which is a small fraction of my sessions. As a skill, those lines cost nothing until the moment they are needed. My dotfiles repo went through the same migration; its CLAUDE.md keeps a short list of hard rules and points at six skills for the detailed workflows.\nThe trap runs the other way. A skill triggers on the model\u0026rsquo;s judgment of relevance, which means a skill is a suggestion with good packaging. When I catch myself writing MUST in a skill description, that is usually the tell that the thing belongs in a hook instead.\nSubagents: when the work needs its own context, or a cheaper model # A subagent runs in a separate context window, with its own tool permissions and, since this is the part that changed my usage bill, its own model.\nI run the main loop on the most capable model available and kept burning through its usage limits, because that model was also doing the grep runs, the batch edits, and the checklist verification. So I defined three agents in ~/.claude/agents/: a bulk worker and a scout on a mid-tier model, and a verifier on the cheapest one. Per Anthropic\u0026rsquo;s model pricing, the cheapest tier costs a tenth of the top one per token. An eight-point pre-publish check on this very post ran on the cheap verifier; the transcript that check produced never touched my main context window either, which is the second, quieter benefit.\nOne gotcha from setting this up: agent definitions register when a session starts. I created the files, dispatched work to them in the same session, and got \u0026ldquo;agent type not found.\u0026rdquo; Restart, and they were there.\nThe decision, as a flowchart # flowchart TD Q1{\"Must it happenevery single time?\"} -- yes --\u003e H[\"Hook(deterministic script)\"] Q1 -- no --\u003e Q2{\"Is it needed inevery session?\"} Q2 -- yes --\u003e C[\"CLAUDE.md(always in context)\"] Q2 -- no --\u003e Q3{\"Is it knowledge the modelshould load on demand?\"} Q3 -- yes --\u003e S[\"Skill(loaded when relevant)\"] Q3 -- no --\u003e Q4{\"Does it need isolated context,different tools, or a cheaper model?\"} Q4 -- yes --\u003e A[\"Subagent(own context and model)\"] Q4 -- no --\u003e P[\"Just put it in the prompt\"] Worked examples from my own config, one per branch: claude-name-session had to happen every time, so it is a hook. \u0026ldquo;Use yadm, not git\u0026rdquo; applies to every dotfiles session, so it lives in that repo\u0026rsquo;s CLAUDE.md (memory docs). Blog conventions load on demand, so they are a skill. Checklist verification wants a cheap model and a disposable context, so it is a subagent. And a one-off \u0026ldquo;rename this variable\u0026rdquo; needs none of the above.\nLessons # The layer question is \u0026ldquo;who decides this runs,\u0026rdquo; not \u0026ldquo;where does config go.\u0026rdquo; Hooks decide with code, skills and delegation decide with model judgment, CLAUDE.md never decides at all. If one missed execution would annoy you, it is a hook. Writing MUST in a skill description is the smell that you picked the wrong layer. Skills are for knowledge that is expensive to carry and cheap to load. If it applies to every session, promote it to CLAUDE.md; if it applies to one task family, keep it a skill. Subagents are a pricing feature as much as a context feature. Route mechanical work to cheap models and keep the expensive context for judgment. Agent definitions load at session start. Create, restart, then dispatch. References # Claude Code hooks documentation Claude Code skills documentation Claude Code subagents documentation Claude Code memory (CLAUDE.md) documentation Anthropic model pricing Configs referenced: claude-name-session (SessionStart hook) and the skills layout in my dotfiles; ~/.claude/agents/ fleet created and tested on this machine ","date":"15 July 2026","externalUrl":null,"permalink":"/posts/claude-code-skills-hooks-subagents/","section":"Posts","summary":" My Claude Code config now holds two hooks, ten skills, and three custom subagents, and most of them started life in the wrong layer. The instruction the model followed nine times out of ten lived in a prompt until I accepted that nine out of ten is a coin I lose every day. The workflow I pasted into chats became a skill. The bulk work that was draining my priciest model's quota became a fleet of cheaper agents. Same features, different failure modes. Taming Claude Code Sessions · Part 6 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS The four layers # Claude Code has four extension points, and they answer four different questions:\n","title":"Hooks are guarantees, skills are knowledge, subagents are other people.","type":"posts"},{"content":"","date":"15 July 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"15 July 2026","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"15 July 2026","externalUrl":null,"permalink":"/tags/skills/","section":"Tags","summary":"","title":"Skills","type":"tags"},{"content":"","date":"15 July 2026","externalUrl":null,"permalink":"/tags/subagents/","section":"Tags","summary":"","title":"Subagents","type":"tags"},{"content":"","date":"15 July 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"15 July 2026","externalUrl":null,"permalink":"/series/taming-claude-code-sessions/","section":"Series","summary":"","title":"Taming Claude Code Sessions","type":"series"},{"content":"","date":"13 July 2026","externalUrl":null,"permalink":"/tags/ai-agents/","section":"Tags","summary":"","title":"Ai-Agents","type":"tags"},{"content":"","date":"13 July 2026","externalUrl":null,"permalink":"/tags/git/","section":"Tags","summary":"","title":"Git","type":"tags"},{"content":" I run four or more Claude Code agents at once, and until recently they all shared one working tree. Two agents editing the same repo means one of them eventually builds against the other's half-finished changes. Git worktrees fix that cleanly. What nobody warned me about is that the fix multiplies a different problem I already had: forgetting which folder a session lives in. Taming Claude Code Sessions · Part 5 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS The symptom # With several agents in one directory, the working tree is shared mutable state. Agent A refactors a partial, agent B runs the build, and B\u0026rsquo;s \u0026ldquo;failure\u0026rdquo; is really A\u0026rsquo;s work in flight. I had been dodging this by scoping agents to different subdirectories, which works until it does not.\nThe textbook answer is git worktrees: one repository, multiple checked-out directories, each on its own branch. Every agent gets an isolated tree, commits land in the same repo, and nothing stomps anything.\ngit worktree add ../myrepo.wt-feature -b feature/thing One command. So I tried it on this blog\u0026rsquo;s repo, with a real task, expecting a five-minute win. The isolation worked as advertised. Everything around it needed more care than the tutorials let on, starting with a build that broke before the first agent typed a word.\nTrap 1: your submodules arrive empty # The fresh worktree looked complete. It was not. First build:\nERROR error building site: assemble: failed to create page from pageMetaSource: failed to extract shortcode: template for shortcode \u0026#34;keywordList\u0026#34; not found That baffling shortcode error means the Hugo theme is missing, because the theme is a git submodule and a new worktree does not populate submodule content. git submodule status shows the tell, a - prefix on the commit hash. The fix is the usual incantation, run inside the worktree:\ngit submodule update --init --recursive On my machine that took 22.6 seconds. Not painful once, but it is a per-worktree cost, and the failure mode points you at the theme, not at the actual cause.\nTrap 2: dependencies do not follow you # node_modules is gitignored, so the worktree starts without it. Every worktree needs its own npm ci before lint or tests work. With a warm npm cache mine took 2.7 seconds; a cold cache costs minutes and disk. The general rule: everything your build needs that git does not track gets re-created per worktree. Budget for it before you spin up four of them.\nTrap 3: one port, many dev servers # Two agents, two worktrees, both want a dev server. The second one dies immediately:\nERROR command error: server startup failed: listen tcp 127.0.0.1:1313: bind: address already in use Hugo defaults to port 1313 no matter which directory it runs from, and the hugo server docs show the flag you need. Assign each worktree its own port and put it in the launch command the agent sees:\nhugo server --port 1314 # worktree A gets 1313, B gets 1314, ... The same applies to any dev server with a default port. Fail fast here is the good outcome; some tools happily connect to the other worktree\u0026rsquo;s server instead, and then you are debugging ghosts.\nThe trap that follows you home: session scatter # The earlier posts in this series exist because I kept losing track of Claude Code sessions. Finding them again and naming tmux windows mostly solved it for one directory per project.\nWorktrees quietly undo that. Claude Code keys its session history to the directory you launched from. On my machine, ~/.claude/projects/ holds one folder per absolute path, eleven of them already, and each worktree adds another. The session where the agent did the refactor now lives under myrepo.wt-feature, not under myrepo. Resume from the main checkout and that session is invisible. Multiply by four agents and a few abandoned worktrees, and \u0026ldquo;where did that conversation go\u0026rdquo; becomes a daily question.\nflowchart TD A[\"myrepo/.git (one repository)\"] --\u003e B[\"myrepo/ (main)\"] A --\u003e C[\"myrepo.wt-auth/ (feature/auth)\"] A --\u003e D[\"myrepo.wt-tests/ (feature/tests)\"] B --\u003e E[\"sessions for path .../myrepo\"] C --\u003e F[\"sessions for path .../myrepo.wt-auth\"] D --\u003e G[\"sessions for path .../myrepo.wt-tests\"] subgraph \"~/.claude/projects/ (one bucket per directory)\" E F G end Two habits keep it under control:\nFirst, a naming hook. My SessionStart hook auto-names every session after its project and branch, so even a session stranded in a worktree path carries a label like the branch it worked on. The hook lives in my dotfiles and reads the payload Claude Code hands it; part 2 of this series covers the hook mechanics.\nSecond, treat worktrees as disposable and delete them the moment the branch merges:\ngit worktree remove ../myrepo.wt-feature git branch -d feature/thing A removed worktree stops accumulating sessions, and the ones it produced stay findable by name instead of by path archaeology. For juggling the live agents themselves, the session picker from part 4 works unchanged; it lists sessions across directories, which suddenly matters a lot more.\nLessons # A fresh worktree is not a fresh clone: submodules arrive empty and gitignored artifacts do not exist. Script the init steps once and run them per worktree. Default ports are global, worktrees are not. Assign a port per worktree before the second dev server starts, not after it fails. Claude Code sessions are keyed to the launch directory. Every worktree is a new session bucket, so name sessions by branch or lose them to path archaeology. Delete worktrees when the branch merges. The isolation is the point; the leftovers are pure liability. References # git-worktree documentation hugo server command reference (the --port flag) Session naming hook: claude-name-session in my dotfiles, a SessionStart hook that titles sessions by project and branch Error messages and timings captured on this machine (macOS, Hugo 0.163.3 extended, Claude Code 2.1.x) while writing this post ","date":"13 July 2026","externalUrl":null,"permalink":"/posts/parallel-agents-git-worktrees/","section":"Posts","summary":" I run four or more Claude Code agents at once, and until recently they all shared one working tree. Two agents editing the same repo means one of them eventually builds against the other's half-finished changes. Git worktrees fix that cleanly. What nobody warned me about is that the fix multiplies a different problem I already had: forgetting which folder a session lives in. Taming Claude Code Sessions · Part 5 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS The symptom # With several agents in one directory, the working tree is shared mutable state. Agent A refactors a partial, agent B runs the build, and B’s “failure” is really A’s work in flight. I had been dodging this by scoping agents to different subdirectories, which works until it does not.\n","title":"Git worktrees gave each Claude agent its own sandbox. And scattered my sessions.","type":"posts"},{"content":"","date":"13 July 2026","externalUrl":null,"permalink":"/tags/tmux/","section":"Tags","summary":"","title":"Tmux","type":"tags"},{"content":"","date":"13 July 2026","externalUrl":null,"permalink":"/tags/worktrees/","section":"Tags","summary":"","title":"Worktrees","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/analytics/","section":"Tags","summary":"","title":"Analytics","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/blowfish/","section":"Tags","summary":"","title":"Blowfish","type":"tags"},{"content":" For six months I assumed nobody could tell whether anyone read this blog, because I had never added analytics. Wiring up Cloudflare Web Analytics by hand taught me two things: the obvious place to paste the snippet would have shipped my Playwright suite's page views into the dashboard, and the dashboard had been quietly counting my visitors for two months anyway. 🧪 Tested with Hugo 0.163.3 · Blowfish 2.104 Publishing into the void # The site\u0026rsquo;s hugo.toml had a googleAnalytics line commented out since roughly the first commit. I never uncommented it. GA4 wants a cookie disclosure, ships a chunky client, and ad blockers eat it anyway, which felt like a lot of ceremony for a personal blog whose one open question was \u0026ldquo;does anybody visit.\u0026rdquo;\nWhat I wanted was small: page views, referrers, no cookies, no banner. The site already deploys to Cloudflare Pages, and Cloudflare Web Analytics is free, cookie-less, and one script tag. Decision made in about a minute.\nThen I went looking for where the tag goes.\nWhat the theme hands you # Blowfish has first-class analytics support. Set one param and you are done:\n# config/_default/params.toml [umamiAnalytics] websiteid = \u0026#34;...\u0026#34; [fathomAnalytics] site = \u0026#34;...\u0026#34; [selineAnalytics] token = \u0026#34;...\u0026#34; Plus GA4 via the top-level googleAnalytics key. Four providers, one line each, and the theme handles script injection for all of them.\nCloudflare Web Analytics is not on the list. No param, no partial. If you want it, you write the tag yourself into layouts/partials/extend-head.html, the theme\u0026rsquo;s designated extension point for exactly this kind of thing. The comment in the theme source even says \u0026ldquo;eg. for custom analytics scripts\u0026rdquo;.\nSo far this looks like a copy-paste job.\nThe part the copy-paste misses # Before pasting, I read how the theme injects its own analytics. From themes/blowfish/layouts/partials/head.html:\n{{/* Analytics */}} {{ if hugo.IsProduction }} {{ partial \u0026#34;analytics/main.html\u0026#34; . }} {{ end }} {{/* Extend head - eg. for custom analytics scripts, etc. */}} {{ if templates.Exists \u0026#34;partials/extend-head.html\u0026#34; }} {{ partialCached \u0026#34;extend-head.html\u0026#34; .Site }} {{ end }} Two things in ten lines.\nFirst: the theme\u0026rsquo;s own analytics sit behind hugo.IsProduction. Your extension point does not. Whatever you put in extend-head.html renders in every environment, including hugo server on your laptop and the hugo server instance my Playwright suite boots for every E2E run. An unguarded beacon means the dashboard counts my own dev sessions and every CI run. With traffic as modest as a personal blog\u0026rsquo;s, CI would outnumber the actual readers in the charts.\nSecond: partialCached with .Site as the cache key. The partial renders once per build and gets reused on every page. For a static script tag that is exactly what you want. For anything page-dependent it is a subtle trap, because your per-page logic would evaluate once, for whichever page happened to render first.\nHugo\u0026rsquo;s environment model does the work # The guard is cheap because Hugo already distinguishes environments: plain hugo builds default to production, hugo server defaults to development. Cloudflare Pages runs hugo --gc --minify, so production builds get the beacon with no extra configuration, and local dev plus CI never see it.\nThe final version of my extend-head.html addition:\n{{ if hugo.IsProduction }} \u0026lt;!-- Cloudflare Web Analytics --\u0026gt; \u0026lt;script type=\u0026#34;module\u0026#34; src=\u0026#34;https://static.cloudflareinsights.com/beacon.min.js\u0026#34; data-cf-beacon=\u0026#39;{\u0026#34;token\u0026#34;: \u0026#34;YOUR_TOKEN\u0026#34;}\u0026#39;\u0026gt;\u0026lt;/script\u0026gt; {{ end }} The token comes from the Cloudflare dashboard under Web Analytics after registering the site. So I went to register the site.\nThe twist in the dashboard # nick-liu.com was already there. Created two months earlier, marked \u0026ldquo;automatic setup\u0026rdquo;, showing real visits for the past 24 hours. Cloudflare had been injecting its beacon at the proxy the whole time, into HTML I never touched. I had analytics all along and did not know where to look.\nRunning both is worse than running either: two beacons per page, two dashboards each holding half the story. I kept the proxy injection and reverted my hand-wired tag. Two months of history beats a fresh counter, and zero lines in the repo beats three.\nThe manual route still matters when your HTML serves from a host Cloudflare does not proxy. This site has a GitHub Pages mirror the proxy never sees; if I ever care about that hostname\u0026rsquo;s numbers, the guarded snippet above is exactly what goes back in.\nOne process note survives the revert: never commit a placeholder token. A guarded beacon with a fake token is invisible in dev, which means the one environment where you would notice the mistake is production.\nLessons # If your theme gates analytics behind hugo.IsProduction, your extend-head partial needs the same guard. The theme\u0026rsquo;s guard does not cover your code. CI is a traffic source. An unguarded beacon on a low-traffic site measures your test suite, not your readers. partialCached is a contract: content renders once per cache key. Static tags are fine. Per-page logic is not. Before wiring any analytics snippet, check whether your platform already injected one. Proxy-level features leave no trace in the repo. Manual injection covers hosts the proxy never sees; proxy injection keeps the repo clean. Pick one. Running both double-counts. References # Blowfish partials documentation (extend-head and analytics) Hugo: hugo.IsProduction Cloudflare Web Analytics documentation Theme source: themes/blowfish/layouts/partials/head.html (Blowfish v2.104.0) ","date":"10 July 2026","externalUrl":null,"permalink":"/posts/cloudflare-web-analytics-hugo/","section":"Posts","summary":" For six months I assumed nobody could tell whether anyone read this blog, because I had never added analytics. Wiring up Cloudflare Web Analytics by hand taught me two things: the obvious place to paste the snippet would have shipped my Playwright suite's page views into the dashboard, and the dashboard had been quietly counting my visitors for two months anyway. 🧪 Tested with Hugo 0.163.3 · Blowfish 2.104 Publishing into the void # The site’s hugo.toml had a googleAnalytics line commented out since roughly the first commit. I never uncommented it. GA4 wants a cookie disclosure, ships a chunky client, and ad blockers eat it anyway, which felt like a lot of ceremony for a personal blog whose one open question was “does anybody visit.”\n","title":"Blowfish supports four analytics providers. Cloudflare Web Analytics isn't one.","type":"posts"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/canonical/","section":"Tags","summary":"","title":"Canonical","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/cloudflare/","section":"Tags","summary":"","title":"Cloudflare","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/cloudflare-pages/","section":"Tags","summary":"","title":"Cloudflare-Pages","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/categories/development-environment/","section":"Categories","summary":"","title":"Development Environment","type":"categories"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/github-pages/","section":"Tags","summary":"","title":"Github-Pages","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":" Paste one of my post links into a social preview and the card comes up with no image. The site itself renders fine, every page, every browser. The culprit was one character in `hugo.toml`: `baseURL = \"/\"`, which quietly turns every absolute URL the site emits into a relative one that only a browser can love. 🧪 Tested with Hugo 0.163.3 · Blowfish 2.104 The symptom # Share cards without images, that was the visible part. View source on any page and the metadata told the fuller story:\n\u0026lt;link rel=\u0026#34;canonical\u0026#34; href=\u0026#34;/\u0026#34;\u0026gt; \u0026lt;meta property=\u0026#34;og:image\u0026#34; content=\u0026#34;/img/og-default.png\u0026#34;\u0026gt; Both relative. The Open Graph spec wants og:image to be a full URL, and social scrapers do not resolve relative paths against the page they happen to be scraping. To LinkedIn or Slack, that image simply does not exist. Same for rel=\u0026quot;canonical\u0026quot;: a canonical of / tells a crawler nothing about which site this content belongs to.\nNothing in day-to-day use surfaces this. Browsers resolve relative URLs without complaint, so the site looked healthy for months while every machine-facing tag it emitted was useless.\nInvestigation # The config had:\n# config/_default/hugo.toml baseURL = \u0026#34;/\u0026#34; Hugo\u0026rsquo;s absURL builds absolute URLs by joining against baseURL. Join against \u0026quot;/\u0026quot; and you get strings that still start with /. Internal navigation never noticed because Blowfish builds its links relatively. The breakage was confined to exactly the outputs I never looked at: canonical tags, Open Graph metadata, the RSS feed\u0026rsquo;s self link, and every \u0026lt;loc\u0026gt; in the sitemap.\nDigging further turned up a second, related fossil: a hand-written static/robots.txt pointing crawlers at https://nickboy.github.io/sitemap.xml. Blowfish ships a robots.txt template that derives the sitemap URL from baseURL, but in this repo the static file is what ended up in the build output, hardcoded domain and all.\nAnd it was hardcoded to the wrong domain, because this site answers on two.\nRoot cause: two domains, both claiming to be canonical # The primary deployment is Cloudflare Pages behind nick-liu.com. A GitHub Actions workflow also publishes the same content to GitHub Pages at nickboy.github.io as a backup. That workflow built with its own override:\nhugo \\ --gc \\ --minify \\ --baseURL \u0026#34;${{ steps.pages.outputs.base_url }}/\u0026#34; So the mirror\u0026rsquo;s pages carried canonicals pointing at the mirror. Two hostnames serving identical content, each declaring itself the original. Google\u0026rsquo;s guidance on duplicate content is direct about this setup: pick one canonical URL per piece of content and make every duplicate point to it. A backup that self-canonicalizes competes with the primary for ranking instead of backing it up.\nThe fix # Three changes, all small:\n# config/_default/hugo.toml baseURL = \u0026#34;https://nick-liu.com/\u0026#34; Then delete static/robots.txt entirely. With the real domain in baseURL, the theme\u0026rsquo;s template generates the correct file on its own:\nUser-agent: * Allow: / Sitemap: https://nick-liu.com/sitemap.xml Finally, drop the --baseURL override from the GitHub Pages workflow. The mirror now builds with the same config as the primary, so its canonical, og:image, RSS, and sitemap URLs all point at nick-liu.com. Navigation on the mirror still works because internal links are relative. The only absolute URLs are the machine-facing ones, and those now agree about who the real site is.\nOne loose end I checked before shipping: with an absolute baseURL, Cloudflare Pages preview deployments also emit nick-liu.com canonicals. That is fine. Cloudflare serves previews with an X-Robots-Tag: noindex header, so they never enter the index in the first place.\nLessons # baseURL = \u0026quot;/\u0026quot; breaks exactly the tags you never look at: canonical, og:image, RSS, sitemap. The browser hides the damage; crawlers see all of it. A mirror that sets its own canonical is not a backup. It is a competitor. A file in static/ can silently shadow a smarter template your theme already ships. Check what actually landed in public/ before writing your own. Verify metadata from the consumer\u0026rsquo;s side. One paste into a social share debugger would have caught this on day one. References # The Open Graph protocol (og:image expects a full URL) Hugo configuration: baseURL Google Search Central: consolidate duplicate URLs Cloudflare Pages: preview deployments ","date":"10 July 2026","externalUrl":null,"permalink":"/posts/hugo-baseurl-dual-domain-canonical/","section":"Posts","summary":" Paste one of my post links into a social preview and the card comes up with no image. The site itself renders fine, every page, every browser. The culprit was one character in `hugo.toml`: `baseURL = \"/\"`, which quietly turns every absolute URL the site emits into a relative one that only a browser can love. 🧪 Tested with Hugo 0.163.3 · Blowfish 2.104 The symptom # Share cards without images, that was the visible part. View source on any page and the metadata told the fuller story:\n","title":"My og:image URLs were broken for months. baseURL was the culprit.","type":"posts"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/privacy/","section":"Tags","summary":"","title":"Privacy","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/seo/","section":"Tags","summary":"","title":"Seo","type":"tags"},{"content":" I changed one sentence in a blog post, opened a pull request, and watched CI spend about 85 seconds installing a headless Chromium to confirm my prose still turned into HTML. The obvious fix, telling the workflow to ignore content paths, would have quietly broken every merge instead. Symptom: a browser for a one-line edit # Every pull request on this blog runs two jobs: a lint job, and a build-and-test job. The second one builds the site with Hugo, link-checks the output with htmltest, then installs a headless Chromium and runs a Playwright suite against a live hugo server. End to end, roughly 85 seconds, and almost all of it is Playwright.\nThat\u0026rsquo;s a fair price when I change a layout or a shortcode. Most of my pull requests aren\u0026rsquo;t that. They\u0026rsquo;re prose: a new post, a fixed typo, a reworded paragraph. Every one of them booted a browser to verify that, yes, my markdown still rendered.\nOn a public repo I might never have noticed. This one is private, so the minutes are metered. A Playwright install that hung for over an hour one afternoon was what finally made me read what CI was doing on a paragraph edit. The answer was: far too much.\nThe trap: don\u0026rsquo;t skip the whole job # The fix looks like four lines. GitHub Actions can filter a workflow by path:\non: pull_request: paths-ignore: - \u0026#39;content/**\u0026#39; Add that and content-only PRs skip the workflow. It also jams the merge button.\nThe problem is branch protection. If build-and-test is a required status check, a PR that never runs it never reports it as passed. The check sits in a permanent \u0026ldquo;waiting for status to be reported\u0026rdquo; state, and the merge stays blocked forever. GitHub\u0026rsquo;s own docs spell this out: a skipped required check is not a passing one.\nWorkflow-level paths-ignore is therefore a trap for any repo with required checks. The job has to run and report success. What I wanted was narrower: run the job, but skip the expensive steps inside it when they don\u0026rsquo;t apply.\nThe fix: gate the steps, not the job # dorny/paths-filter runs inside the job and reports which path groups changed. One step after checkout:\n- name: Detect changes that require browser E2E id: filter uses: dorny/paths-filter@v3 with: filters: | e2e: - \u0026#39;layouts/**\u0026#39; - \u0026#39;assets/**\u0026#39; - \u0026#39;config/**\u0026#39; - \u0026#39;themes/**\u0026#39; - \u0026#39;tests/**\u0026#39; - \u0026#39;playwright.config.*\u0026#39; - \u0026#39;package.json\u0026#39; - \u0026#39;package-lock.json\u0026#39; - \u0026#39;.github/workflows/**\u0026#39; Then every Playwright-related step carries one condition:\n- name: Run Playwright tests if: steps.filter.outputs.e2e == \u0026#39;true\u0026#39; run: npx playwright test --project=chromium The browser install, the cache restore, and the test run are all gated on e2e == 'true'. A prose PR touches none of those paths, so e2e is false and the job walks straight past the browser work. It still runs and still reports green, so branch protection stays satisfied.\nThe Hugo build and htmltest steps stay unconditional. A typo fix still gets compiled and link-checked. It just doesn\u0026rsquo;t get a browser.\nThe permission you\u0026rsquo;ll hit first # My first run died in nine seconds:\nResource not accessible by integration On a pull request, dorny/paths-filter works out the changed files by asking the GitHub API, and this repo\u0026rsquo;s default GITHUB_TOKEN couldn\u0026rsquo;t read pull requests. The fix is one block, scoped to the job that needs it:\npermissions: contents: read pull-requests: read On push events the action diffs against the previous commit with plain git and never touches the token. Pull requests go through the API, so they need pull-requests: read. Grant it and the failure disappears.\nDon\u0026rsquo;t quietly drop the check you were skipping # Cutting the browser tests on content PRs carries a real risk: one of those tests was the only thing guarding a genuine failure mode. Put a shortcode inside a markdown heading and Blowfish renders it in the output as the literal placeholder Hugo keeps internally for a shortcode it never resolved, a token that begins with HAHAHUGO. My Playwright suite asserted that token never shows up on a few key pages. Skip Playwright, and a malformed shortcode in a new post sails through unseen.\nThat marker is cursed enough that I can\u0026rsquo;t spell it out in full anywhere on this page. Hugo uses the exact characters as its own internal placeholder, so a page that contains the whole token refuses to build, and the grep below would flag the page as broken anyway. This post keeps the string in two pieces. So does the check.\nSo I didn\u0026rsquo;t delete that coverage. I moved it somewhere cheaper. A grep over the built public/ directory catches the same thing in milliseconds, and it runs on every PR, gated by nothing:\n- name: Check for unrendered shortcodes run: | # Hugo emits this marker for a shortcode it could not render. Built in two # pieces so the workflow file never holds the whole token. marker=\u0026#34;HAHAHUGO\u0026#34;\u0026#34;SHORTCODE\u0026#34; if grep -rl \u0026#34;$marker\u0026#34; public/; then echo \u0026#34;::error::Found unrendered shortcode markers in build output\u0026#34; exit 1 fi echo \u0026#34;No unrendered shortcodes in build output\u0026#34; This is stronger than what it replaced. The browser test checked three specific pages; the grep checks the entire built site. Content PRs lost a headless browser and gained wider coverage of the one thing that breaks in prose.\nThe result # A content-only PR now finishes build-and-test in about 15 seconds, down from 85. The first prose PR after the change, coincidentally a batch of blog edits, proved it on the nose.\nMost of that 70-second drop is the Chromium that no longer gets installed to inspect a paragraph. On a private repo, where GitHub bills each job rounded up to the minute, that\u0026rsquo;s a billed minute handed back on every prose PR. The bigger win is quieter: the slowest and flakiest stretch of the pipeline, downloading and booting a browser, no longer runs on the changes that never needed it.\nOne caveat on the accounting. The 15 seconds is the build-and-test job. The lint job still takes its usual ~25 seconds and now sets the wall-clock for a content PR. I\u0026rsquo;m fine with that. Markdown linting should run on a post.\nLessons # For a content-heavy repo, ask what each CI step actually protects against on a prose PR. Browser tests guard rendering and JS; a typo touches neither. paths-ignore at the workflow level skips the entire job, which leaves a required status check pending forever and blocks the merge. Gate the steps inside the job instead. When you stop running an expensive check, find the one failure it really caught and re-cover it cheaply. A grep over the build output beat a headless browser at catching unrendered shortcodes, and covered more pages. dorny/paths-filter needs pull-requests: read on pull-request events; it asks the API for the changed files. Scope the permission to the job, not the whole workflow. The cheapest test is the one you don\u0026rsquo;t run when it can\u0026rsquo;t fail. References # dorny/paths-filter (path-based step gating for GitHub Actions) Troubleshooting required status checks (GitHub Docs: skipped vs. passing checks) Automatic token authentication (GitHub Docs: GITHUB_TOKEN permissions) About billing for GitHub Actions (GitHub Docs: per-job, per-minute rounding) ","date":"21 June 2026","externalUrl":null,"permalink":"/posts/content-pr-ci-path-filter/","section":"Posts","summary":" I changed one sentence in a blog post, opened a pull request, and watched CI spend about 85 seconds installing a headless Chromium to confirm my prose still turned into HTML. The obvious fix, telling the workflow to ignore content paths, would have quietly broken every merge instead. Symptom: a browser for a one-line edit # Every pull request on this blog runs two jobs: a lint job, and a build-and-test job. The second one builds the site with Hugo, link-checks the output with htmltest, then installs a headless Chromium and runs a Playwright suite against a live hugo server. End to end, roughly 85 seconds, and almost all of it is Playwright.\n","title":"A typo fix shouldn't boot a browser in CI. Mine did for months.","type":"posts"},{"content":"","date":"21 June 2026","externalUrl":null,"permalink":"/tags/bash/","section":"Tags","summary":"","title":"Bash","type":"tags"},{"content":"","date":"21 June 2026","externalUrl":null,"permalink":"/tags/beginners/","section":"Tags","summary":"","title":"Beginners","type":"tags"},{"content":"","date":"21 June 2026","externalUrl":null,"permalink":"/tags/ci/","section":"Tags","summary":"","title":"Ci","type":"tags"},{"content":"","date":"21 June 2026","externalUrl":null,"permalink":"/tags/developer-tools/","section":"Tags","summary":"","title":"Developer-Tools","type":"tags"},{"content":"","date":"21 June 2026","externalUrl":null,"permalink":"/tags/github-actions/","section":"Tags","summary":"","title":"Github-Actions","type":"tags"},{"content":" If you use tmux, you've hit this: ten windows open and they're all named `zsh` or `node`. Which one had your AI agent running? No idea. Let's make tmux label windows usefully. Taming Claude Code Sessions · Part 3 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS / Linux New to tmux? It\u0026rsquo;s a \u0026ldquo;terminal multiplexer\u0026rdquo;: it splits one terminal into many windows and panes that survive disconnects. The only vocabulary you need here: a window is like a browser tab inside tmux; the bar at the bottom lists them. The prefix is the key you press before a tmux command, commonly Ctrl+b (mine is Ctrl+a). Why everything is named zsh # By default tmux has a setting called automatic-rename turned on. It renames each window after whatever program is running in it. A shell? zsh. A Node program (like Claude Code)? node. Helpful in theory, useless when everything collapses to the same word.\nWe want: when I launch my AI agent in a window, name that window something I\u0026rsquo;ll recognize (like 🤖 my-app/feature-x) and put it back to normal when I\u0026rsquo;m done.\nStep 1: rename a window yourself (the manual version) # You can rename the current window any time:\ntmux rename-window \u0026#34;my-app\u0026#34; But the moment a new command runs, automatic-rename overwrites it. To make a name stick, turn auto-rename off for that window first:\ntmux set-window-option automatic-rename off tmux rename-window \u0026#34;🤖 my-app\u0026#34; Doing that by hand every time is no fun. Let\u0026rsquo;s automate it.\nStep 2: a wrapper that labels the window automatically # A shell function can \u0026ldquo;wrap\u0026rdquo; a command: do something before it runs, run it, then clean up after. Here\u0026rsquo;s one that wraps claude (swap in any tool you like). Add it to your ~/.zshrc (or ~/.bashrc):\nclaude() { # Not in tmux? Just run the real claude normally. if [ -z \u0026#34;$TMUX\u0026#34; ]; then command claude \u0026#34;$@\u0026#34; return fi # Build a label from the project folder + git branch. local root label branch prev root=\u0026#34;$(git rev-parse --show-toplevel 2\u0026gt;/dev/null)\u0026#34; label=\u0026#34;$(basename \u0026#34;${root:-$PWD}\u0026#34;)\u0026#34; branch=\u0026#34;$(git symbolic-ref --quiet --short HEAD 2\u0026gt;/dev/null)\u0026#34; [ -n \u0026#34;$branch\u0026#34; ] \u0026amp;\u0026amp; [ \u0026#34;$branch\u0026#34; != \u0026#34;main\u0026#34; ] \u0026amp;\u0026amp; label=\u0026#34;$label/$branch\u0026#34; # Remember the current auto-rename setting so we can restore it. prev=\u0026#34;$(tmux show-window-options -v automatic-rename 2\u0026gt;/dev/null)\u0026#34; tmux set-window-option automatic-rename off tmux rename-window \u0026#34;🤖 $label\u0026#34; command claude \u0026#34;$@\u0026#34; # \u0026lt;- run the real claude; we wait here until it exits # Put things back the way they were. if [ \u0026#34;$prev\u0026#34; = \u0026#34;off\u0026#34; ]; then tmux set-window-option automatic-rename off else tmux set-window-option automatic-rename on fi } Reload your shell (exec zsh) and run claude inside tmux. The window becomes 🤖 my-app/feature-x while it runs, then snaps back when you exit.\nA couple of things worth understanding:\ncommand claude runs the real claude program, not our function again (otherwise we\u0026rsquo;d loop forever). The line after command claude doesn\u0026rsquo;t run until claude exits, which is why restoring auto-rename \u0026ldquo;after\u0026rdquo; works. Claude handles Ctrl+C itself and exits cleanly, so the restore still runs. Step 3: show the name in your terminal\u0026rsquo;s tab, too # The window name now shows in tmux\u0026rsquo;s status bar. But you can also push it up to your terminal app\u0026rsquo;s tab title (Ghostty, iTerm, etc.). Two lines in ~/.tmux.conf:\nset -g set-titles on set -g set-titles-string \u0026#34;#{window_name}\u0026#34; set-titles on lets tmux set the outer terminal\u0026rsquo;s title; set-titles-string says what to send. We use #{window_name} on purpose.\nWhy not the pane title (#T)? Because some full-screen terminal apps (Claude Code among them) constantly rewrite the pane title with a spinner/status, and if you forward that, your tab title flickers like crazy. The window name is stable, so the tab stays calm and readable.\nReload with tmux source-file ~/.tmux.conf (or your reload key) and your terminal tab will read 🤖 my-app/feature-x.\nHow to test it without messing up your real tmux # Nice trick: tmux can run a throwaway server on a separate \u0026ldquo;socket\u0026rdquo; with -L, so you can experiment without touching your real sessions:\ntmux -L scratch new-session -d # start an isolated server tmux -L scratch rename-window hello tmux -L scratch display-message -p \u0026#39;#{window_name}\u0026#39; # → hello tmux -L scratch kill-server # clean up, your real tmux untouched Recap # automatic-rename is why windows are all zsh: turn it off for a window to pin a name. A shell wrapper can label the window on launch and restore it on exit. set-titles on + set-titles-string \u0026quot;#{window_name}\u0026quot; mirrors the name to your terminal tab (use the window name to avoid flicker). So far we\u0026rsquo;ve been running one agent per window. What if you want to run a bunch of them at once and jump between them? That\u0026rsquo;s Part 4.\n","date":"21 June 2026","externalUrl":null,"permalink":"/posts/tmux-window-titles/","section":"Posts","summary":" If you use tmux, you've hit this: ten windows open and they're all named `zsh` or `node`. Which one had your AI agent running? No idea. Let's make tmux label windows usefully. Taming Claude Code Sessions · Part 3 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS / Linux New to tmux? It’s a “terminal multiplexer”: it splits one terminal into many windows and panes that survive disconnects. The only vocabulary you need here: a window is like a browser tab inside tmux; the bar at the bottom lists them. The prefix is the key you press before a tmux command, commonly Ctrl+b (mine is Ctrl+a). Why everything is named zsh # By default tmux has a setting called automatic-rename turned on. It renames each window after whatever program is running in it. A shell? zsh. A Node program (like Claude Code)? node. Helpful in theory, useless when everything collapses to the same word.\n","title":"Make tmux Show What Each Window Is Doing","type":"posts"},{"content":"","date":"21 June 2026","externalUrl":null,"permalink":"/tags/playwright/","section":"Tags","summary":"","title":"Playwright","type":"tags"},{"content":"","date":"21 June 2026","externalUrl":null,"permalink":"/tags/productivity/","section":"Tags","summary":"","title":"Productivity","type":"tags"},{"content":" Once you're comfortable with AI coding agents, you start running several at once: one refactoring here, one writing tests there, one stuck waiting for your approval. Keeping them straight is its own little skill. Taming Claude Code Sessions · Part 4 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS / Linux Here are two ways to do it: a lightweight tmux plugin, and (briefly) dedicated \u0026ldquo;AI terminal\u0026rdquo; apps.\nOption A: a tmux plugin (lightweight, works over SSH) # tmux-claude-session-manager adds two keystrokes to tmux:\nprefix + y: launch (or jump back to) a Claude session for the current folder. prefix + u: open a searchable picker of your live Claude sessions, showing each one\u0026rsquo;s status (🔴 working / 🟡 waiting for you / 🟢 idle) plus a live preview, with the ones needing attention sorted to the top. It uses fzf (a fast fuzzy finder) for the picker, so you just start typing to filter.\nInstalling it (with TPM) # Most people manage tmux plugins with TPM, the Tmux Plugin Manager. If you don\u0026rsquo;t have it yet:\ngit clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm …and add this near the bottom of ~/.tmux.conf (it must come before the TPM run line):\nrun \u0026#39;~/.tmux/plugins/tpm/tpm\u0026#39; Now add the plugin. Put this line above that run line:\nset -g @plugin \u0026#39;craftzdog/tmux-claude-session-manager\u0026#39; Reload tmux (prefix + r or tmux source-file ~/.tmux.conf), then press prefix + I (capital i) to install. Done. prefix + y and prefix + u now work. You\u0026rsquo;ll also need fzf installed (brew install fzf).\nMaking the status dots light up # The picker can show working/waiting/idle, but it needs to know the state. It learns this from Claude Code hooks (we met hooks in Part 2). The plugin ships a tiny script, scripts/state.sh, that you wire into a few hook events in ~/.claude/settings.json:\n{ \u0026#34;hooks\u0026#34;: { \u0026#34;UserPromptSubmit\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;~/.tmux/plugins/tmux-claude-session-manager/scripts/state.sh working\u0026#34; } ] } ], \u0026#34;Notification\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;permission_prompt\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;~/.tmux/plugins/tmux-claude-session-manager/scripts/state.sh waiting\u0026#34; } ] } ], \u0026#34;Stop\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;~/.tmux/plugins/tmux-claude-session-manager/scripts/state.sh idle\u0026#34; } ] } ] } } (Merge these into any hooks you already have rather than replacing the whole block.) Translation: \u0026ldquo;when I submit a prompt → mark working; when Claude asks permission → waiting; when it stops → idle.\u0026rdquo; Now the dots are live. The three hook events drive a simple state machine:\nstateDiagram-v2 [*] --\u003e working: UserPromptSubmit working --\u003e waiting: Notification(permission_prompt) waiting --\u003e working: you approve working --\u003e idle: Stop idle --\u003e working: UserPromptSubmit note right of working : 🔴 running note right of waiting : 🟡 needs you note right of idle : 🟢 done Heads-up on hooks and trust. You\u0026rsquo;re telling Claude Code to run a third-party script on its lifecycle events. Read state.sh first (it\u0026rsquo;s short; it just records a status into a tmux variable). Only wire in hooks you understand and trust. Why this is great for remote servers # If you SSH into a remote machine and run Claude in tmux there, this plugin runs on that machine, so your picker and status work on the remote too. That\u0026rsquo;s a big deal: GUI-based managers on your laptop can\u0026rsquo;t see what\u0026rsquo;s happening inside an SSH session, but a tmux plugin lives right where the agents do.\nOption B: a dedicated \u0026ldquo;AI terminal\u0026rdquo; app # There\u0026rsquo;s a newer category of macOS apps built specifically for running many agents in parallel, with sidebars showing each agent\u0026rsquo;s status, git branch, and so on. Two popular ones:\ncmux: a native macOS terminal for parallel AI agents (vertical tabs, per-pane notifications). Unrelated to tmux despite the name. Supacode: a terminal \u0026ldquo;command center\u0026rdquo; (built on Ghostty\u0026rsquo;s engine) that runs each agent in its own isolated git worktree, with GitHub integration for opening PRs and reviewing CI checks. These are slick and worth a look if you do a lot of local, parallel, many-agents-on-one-repo work.\ntmux vs. a dedicated app: how to choose # If you… Lean toward Mostly work on remote servers over SSH tmux (the app can\u0026rsquo;t reach remote agents) Already love tmux + your shell setup tmux (keep your muscle memory) Want sessions to survive disconnects tmux (built-in; reattach any time) Do bursts of 3+ local agents on one repo a dedicated app can shine Want zero terminal config, a GUI sidebar a dedicated app There\u0026rsquo;s no wrong answer, and they\u0026rsquo;re not exclusive. You can keep tmux as home base and trial a dedicated app for heavy local days. For my own SSH-heavy workflow, staying in tmux won out.\nThe whole series, in one breath # Part 1: Find them 1 / 4 Sessions live in ~/.claude/projects; find them with claude -r → Ctrl+A. Read → Part 2: Name them 2 / 4 A SessionStart hook auto-names sessions so search actually works. Read → Part 3: Label tmux 3 / 4 A shell wrapper + set-titles label your tmux window and terminal tab. Read → Part 4: Juggle many 4 / 4 A tmux plugin (or a dedicated app) helps you manage many live agents. (this post) Put together, you go from \u0026ldquo;where did that conversation go?\u0026rdquo; to \u0026ldquo;type three letters, hit Enter, back in business.\u0026rdquo; Happy hacking.\n","date":"21 June 2026","externalUrl":null,"permalink":"/posts/managing-many-ai-agents/","section":"Posts","summary":" Once you're comfortable with AI coding agents, you start running several at once: one refactoring here, one writing tests there, one stuck waiting for your approval. Keeping them straight is its own little skill. Taming Claude Code Sessions · Part 4 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS / Linux Here are two ways to do it: a lightweight tmux plugin, and (briefly) dedicated “AI terminal” apps.\n","title":"Running Several AI Coding Agents Without Losing Track","type":"posts"},{"content":" You're deep in a great Claude Code conversation. You close the terminal. The next day you want to pick up where you left off… and you can't find it. Sound familiar? Let's fix that. Taming Claude Code Sessions · Part 1 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS / Linux What is a \u0026ldquo;session,\u0026rdquo; and where does it go? # Every time you run claude, you start a session, one conversation, with its full history. When you quit, that history doesn\u0026rsquo;t vanish. Claude Code saves it to disk, organized per project folder, here:\n~/.claude/projects/\u0026lt;your-project\u0026gt;/\u0026lt;session-id\u0026gt;.jsonl Each .jsonl file is one session. So your conversations are all still there. The problem is finding the right one later.\nThe built-in way to get back: /resume and --continue # Claude Code already ships two ways to return to a past session.\nContinue the most recent one (in the current folder):\nclaude --continue # or the short form: claude -c Pick from a list: run this and you get an interactive picker:\nclaude --resume # or: claude -r Or, from inside a running session, type the slash command:\n/resume The picker shows your past sessions with a summary, how long ago you used them, and (if you named them, more on that soon) a title. Use the arrow keys, press Enter to resume, Esc to cancel.\nThe shortcut nobody tells you about: Ctrl+A # Here\u0026rsquo;s the catch that trips up most people: by default, the picker only shows sessions from the folder you\u0026rsquo;re currently in. So if you\u0026rsquo;re in the wrong directory, your session looks \u0026ldquo;lost\u0026rdquo; when it\u0026rsquo;s really just filtered out.\nThe fix: open the picker and press Ctrl+A. That widens it to every project on your machine. Suddenly everything is there.\nA few more handy keys inside the picker (they\u0026rsquo;re shown along the bottom, so you don\u0026rsquo;t have to memorize them):\nKey What it does (type anything) Fuzzy-search the list Ctrl+A Show sessions from all projects Ctrl+B Filter to your current git branch Ctrl+R Rename the highlighted session Enter / Esc Resume / cancel Running inside tmux? Ctrl+B is also the default tmux prefix, so tmux may swallow it before the picker sees it. Either trigger your branch filter another way or remap your tmux prefix (mine is Ctrl+A). We get into tmux properly in Part 3. Tip: you can also paste a GitHub pull-request URL into the search to find the session that created it.\nResume by name (once your sessions have names) # If a session has a name, you can jump straight to it:\nclaude --resume \u0026#34;my-feature\u0026#34; # exact match resumes immediately claude -r my-feature # partial: opens the picker pre-filtered The real fix: give your sessions names # Searching only works if there\u0026rsquo;s something readable to search for. Out of the box, sessions are unnamed, so the picker falls back to auto-summaries that all look kind of the same.\nThree ways to name a session:\nAt launch: claude --name \u0026quot;auth-refactor\u0026quot; While running: type /rename auth-refactor Automatically: when you accept a plan in plan mode, Claude names the session from the plan. Naming the keepers turns /resume from \u0026ldquo;scroll and squint\u0026rdquo; into \u0026ldquo;type three letters and hit Enter.\u0026rdquo;\nYour new cheat sheet # claude -c # resume the most recent session here claude -r # open the picker → press Ctrl+A for ALL projects claude -r my-feature # jump to a session by name /rename my-feature # name the session you\u0026#39;re in (do this for keepers!) That\u0026rsquo;s 90% of the battle. But typing /rename every time is a chore, and you\u0026rsquo;ll forget. In Part 2 we\u0026rsquo;ll write a tiny hook that names every session automatically, so the picker is always searchable without you lifting a finger.\n","date":"21 June 2026","externalUrl":null,"permalink":"/posts/find-claude-code-sessions/","section":"Posts","summary":" You're deep in a great Claude Code conversation. You close the terminal. The next day you want to pick up where you left off… and you can't find it. Sound familiar? Let's fix that. Taming Claude Code Sessions · Part 1 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS / Linux What is a “session,” and where does it go? # Every time you run claude, you start a session, one conversation, with its full history. When you quit, that history doesn’t vanish. Claude Code saves it to disk, organized per project folder, here:\n","title":"Stop Losing Your Claude Code Conversations","type":"posts"},{"content":"","date":"21 June 2026","externalUrl":null,"permalink":"/tags/terminal/","section":"Tags","summary":"","title":"Terminal","type":"tags"},{"content":" In Part 1 we learned that named sessions are easy to find. Now let's make naming automatic with a hook. This is also a perfect first hook project, so I'll explain the whole idea from scratch. Taming Claude Code Sessions · Part 2 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS / Linux Part 1 left us with one chore: you still have to remember to name your sessions. Let\u0026rsquo;s delete that chore.\nWhat is a hook? # A hook is just a small command that Claude Code runs for you at certain moments: when a session starts, when it finishes a turn, when it asks permission, and so on. You tell Claude \u0026ldquo;when X happens, run this script,\u0026rdquo; and it does. That\u0026rsquo;s it.\nHooks live in your settings file:\n~/.claude/settings.json We\u0026rsquo;ll use the SessionStart hook, which fires right when a session begins. That\u0026rsquo;s the perfect moment to give it a name.\nHow a hook talks to your script # When the hook fires, Claude Code runs your command and sends it a little blob of JSON on standard input describing what\u0026rsquo;s happening. For SessionStart, that JSON looks like this:\n{ \u0026#34;session_id\u0026#34;: \u0026#34;6b603cbb-…\u0026#34;, \u0026#34;cwd\u0026#34;: \u0026#34;/Users/you/projects/my-app\u0026#34;, \u0026#34;source\u0026#34;: \u0026#34;startup\u0026#34;, \u0026#34;hook_event_name\u0026#34;: \u0026#34;SessionStart\u0026#34;, \u0026#34;transcript_path\u0026#34;: \u0026#34;/Users/you/.claude/projects/…/6b603cbb-….jsonl\u0026#34; } Two fields matter to us:\ncwd: the folder the session is running in (we\u0026rsquo;ll name the session after it). source: why SessionStart fired: startup (brand new), resume (you came back to one), clear, or compact. To set the session\u0026rsquo;s title, our script just prints a specific bit of JSON back out:\n{ \u0026#34;hookSpecificOutput\u0026#34;: { \u0026#34;hookEventName\u0026#34;: \u0026#34;SessionStart\u0026#34;, \u0026#34;sessionTitle\u0026#34;: \u0026#34;my-app\u0026#34; } } sessionTitle has the same effect as typing /rename. The whole round trip is just two JSON messages:\nflowchart LR A[\"Claude CodeSessionStart fires\"] --\u003e|\"JSON on stdincwd · source · session_id\"| B[\"your scriptclaude-name-session\"] B --\u003e|\"JSON on stdouthookSpecificOutput.sessionTitle\"| C[\"Claude Codesets the session title\"] The script # Save this as ~/.local/bin/claude-name-session:\n#!/usr/bin/env bash # Name each Claude Code session after its project folder (and git branch), # so it\u0026#39;s easy to find later in /resume. input=\u0026#34;$(cat)\u0026#34; # read the JSON Claude sent us src=\u0026#34;$(printf \u0026#39;%s\u0026#39; \u0026#34;$input\u0026#34; | jq -r \u0026#39;.source\u0026#39;)\u0026#34; # startup / resume / ... cwd=\u0026#34;$(printf \u0026#39;%s\u0026#39; \u0026#34;$input\u0026#34; | jq -r \u0026#39;.cwd\u0026#39;)\u0026#34; # Only name a brand-new session, or a resumed one that has NO name yet. # This way we never overwrite a name you set by hand with /rename. if [ \u0026#34;$src\u0026#34; = \u0026#34;resume\u0026#34; ]; then existing=\u0026#34;$(printf \u0026#39;%s\u0026#39; \u0026#34;$input\u0026#34; | jq -r \u0026#39;.session_title // empty\u0026#39;)\u0026#34; [ -n \u0026#34;$existing\u0026#34; ] \u0026amp;\u0026amp; exit 0 elif [ \u0026#34;$src\u0026#34; != \u0026#34;startup\u0026#34; ]; then exit 0 fi # Build the label: \u0026#34;\u0026lt;project\u0026gt;\u0026#34; or \u0026#34;\u0026lt;project\u0026gt;/\u0026lt;branch\u0026gt;\u0026#34;. root=\u0026#34;$(git -C \u0026#34;$cwd\u0026#34; rev-parse --show-toplevel 2\u0026gt;/dev/null)\u0026#34; label=\u0026#34;$(basename \u0026#34;${root:-$cwd}\u0026#34;)\u0026#34; branch=\u0026#34;$(git -C \u0026#34;$cwd\u0026#34; symbolic-ref --quiet --short HEAD 2\u0026gt;/dev/null)\u0026#34; case \u0026#34;$branch\u0026#34; in \u0026#34;\u0026#34; | main | master | trunk) ;; # skip noisy/default branch names *) label=\u0026#34;$label/$branch\u0026#34; ;; esac # Hand the title back to Claude Code. jq -n --arg t \u0026#34;$label\u0026#34; \\ \u0026#39;{hookSpecificOutput: {hookEventName: \u0026#34;SessionStart\u0026#34;, sessionTitle: $t}}\u0026#39; Make it executable:\nchmod +x ~/.local/bin/claude-name-session This uses jq (a tiny JSON tool) and git for the branch name. Installing jq:\nmacOS Linux No jq (python3) brew install jq sudo apt install jq # Debian/Ubuntu # or: sudo dnf install jq (Fedora) On a minimal box without jq, parse the same JSON with python3 instead:\ncwd=\u0026#34;$(printf \u0026#39;%s\u0026#39; \u0026#34;$input\u0026#34; | python3 -c \u0026#39;import sys,json;print(json.load(sys.stdin)[\u0026#34;cwd\u0026#34;])\u0026#39;)\u0026#34; Why \u0026ldquo;only name on startup (or empty resume)\u0026rdquo;? # A subtle but important detail. SessionStart fires again every time you resume a session. If we blindly set the title each time, we\u0026rsquo;d stomp on any name you carefully set with /rename. So:\nstartup → always name it (it has no name yet). resume → only name it if it\u0026rsquo;s still unnamed (handy for back-filling old sessions you created before you had this hook). clear / compact → leave it alone. That rule is \u0026ldquo;safe by design\u0026rdquo;: it can only ever add a name, never clobber one.\nWire it up # Add the hook to ~/.claude/settings.json. If you already have a hooks section, just add the SessionStart entry alongside what\u0026rsquo;s there:\n{ \u0026#34;hooks\u0026#34;: { \u0026#34;SessionStart\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;~/.local/bin/claude-name-session\u0026#34; } ] } ] } } Test it (safely) # Start a session in any project, then check it got a title:\ncd ~/projects/my-app claude -p \u0026#34;say hi\u0026#34; # -p = one-shot, headless; great for a quick test # peek at the newest session file for this folder: ls -t ~/.claude/projects/-Users-*my-app/*.jsonl | head -1 | xargs grep -m1 custom-title # → {\u0026#34;type\u0026#34;:\u0026#34;custom-title\u0026#34;,\u0026#34;customTitle\u0026#34;:\u0026#34;my-app/your-branch\u0026#34;} Now open claude -r, hit Ctrl+A, and watch your sessions show up with real names.\nA safety word # Hooks run real commands on your machine on Claude\u0026rsquo;s lifecycle events, so keep them small, readable, and trusted, exactly like the one above. Don\u0026rsquo;t paste in hook scripts you don\u0026rsquo;t understand. (Same caution as any shell snippet from the internet.) Going further # A few hardening ideas once you\u0026rsquo;re comfortable:\nFall back to python3 if jq isn\u0026rsquo;t installed, so it also works on minimal remote servers. If your home directory is managed by a dotfile manager like yadm, git won\u0026rsquo;t see it as a repo, so detect that case and use your dotfile manager\u0026rsquo;s branch instead. Next up: your sessions are named, but your tmux windows are probably still all called zsh. Part 3 fixes that.\n","date":"21 June 2026","externalUrl":null,"permalink":"/posts/first-claude-code-hook/","section":"Posts","summary":" In Part 1 we learned that named sessions are easy to find. Now let's make naming automatic with a hook. This is also a perfect first hook project, so I'll explain the whole idea from scratch. Taming Claude Code Sessions · Part 2 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS / Linux Part 1 left us with one chore: you still have to remember to name your sessions. Let’s delete that chore.\n","title":"Your First Claude Code Hook: Auto-Name Every Session","type":"posts"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/accessibility/","section":"Tags","summary":"","title":"Accessibility","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/ai/","section":"Tags","summary":"","title":"Ai","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/algorithms/","section":"Tags","summary":"","title":"Algorithms","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/api/","section":"Tags","summary":"","title":"Api","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/series/building-claudedeck/","section":"Series","summary":"","title":"Building ClaudeDeck","type":"series"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/bun/","section":"Tags","summary":"","title":"Bun","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/carbon/","section":"Tags","summary":"","title":"Carbon","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/cgeventpost/","section":"Tags","summary":"","title":"Cgeventpost","type":"tags"},{"content":" The AI coding tool landscape in 2026 has finally settled into four serious players: Claude Code , Cursor , GitHub Copilot , and Windsurf . I've used all four on real work. This is the honest comparison. Forget feature checklists. What matters is how each tool feels under real engineering work, the kind I do every day as a senior software engineer at Meta. I built this site primarily with Claude Code, but I\u0026rsquo;ve put serious hours into the others.\nWhat I Evaluated # Five things matter in an AI coding tool:\nContext understanding: Can it reason about your whole project, or just the open file? Autonomy: Can it execute multi-step tasks, or does it need hand-holding? Code quality: Does it generate production-ready code, or prototyping-quality code? Speed: How fast is the feedback loop from prompt to working code? Cost: What are you actually paying per month, and what does it get you? Each tool occupies a different niche.\nClaude Code # Agentic Full-project context Multi-surface Claude Code is no longer terminal-only. It runs as a CLI, a VS Code extension, a JetBrains plugin, a Mac/Windows desktop app, a web app at claude.ai/code, and on iOS. The conversation moves between surfaces: start a task in your terminal, hand it off to your phone, finish in the desktop app.\nWhat sets it apart: agentic workflow plus the extensibility model. Claude Code creates files, runs commands, executes tests, iterates on results. Plan Mode lets you review a multi-step approach before any code gets written. And the ecosystem around it (Skills (loadable markdown procedures), Subagents (isolated contexts for parallel work), MCP servers (external tool access), Plugins (bundles of all of the above), Hooks (deterministic event triggers)) makes it a customizable engineering harness rather than a single chat product.\nThe CLAUDE.md convention is still the highest-leverage feature. Drop a file describing your project\u0026rsquo;s architecture, conventions, and constraints into the repo root, and every session starts with that context loaded. The onboarding doc your AI pair programmer actually reads.\nWhere it shines:\nFull-stack projects touching config, code, CI, and content Scaffolding new features or migrating between frameworks Any task that requires understanding the project as a whole Workflows you want to package and share (Skills make procedures reusable across projects) Where it\u0026rsquo;s weaker:\nInline editing within a file: you\u0026rsquo;re working at the conversation level, not the cursor level Discovery: the feature surface (skills, subagents, plugins, hooks, MCP) is large enough that newcomers can miss the right tool for the job Tight inline-diff IDE workflows feel less native than in Cursor or Copilot Cost: Tied to your Anthropic API usage or Claude Pro/Max subscription. The Max plan gives the most predictable spend for heavy agentic use.\nCursor # VS Code fork Inline editing Agent mode Cursor is a fork of VS Code with AI integrated directly into the editor. Inline completions, a chat sidebar, and code selection with diff previews, all inside the familiar VS Code interface.\nWhat sets it apart: structured control over inline edits. Highlight a function, ask for a refactor, see a diff preview before accepting. Composer handles multi-file edits with a UI that shows every change across your project. Agent mode layers autonomous multi-step work on top: terminal commands, file creation, iterative debugging. Closer to what Claude Code does, but inside the editor.\nWhere it shines:\nFocused refactoring within a known codebase Visual diff review before accepting changes Teams already on VS Code, with zero workflow disruption Tab completion that\u0026rsquo;s genuinely fast and context-aware Where it\u0026rsquo;s weaker:\nCross-cutting concerns spanning dozens of files still favor a true agentic terminal flow \u0026ldquo;Auto mode\u0026rdquo; picks the model for you; reaching for a specific frontier model burns your credit pool Extension ecosystem is less mature than Claude Code\u0026rsquo;s Skills/Plugins surface Cost (May 2026):\nHobby (free): strict caps, evaluation only Pro ($20/mo): $20 in usage credits, unlimited tab completions, unlimited Auto mode Pro+ ($60/mo): 3x the usage credits Ultra ($200/mo): 20x usage multiplier, priority feature access Business ($40/seat/mo): Pro plus admin controls and shared team rules The old \u0026ldquo;unlimited everything for $20\u0026rdquo; framing is gone. Premium-model requests now draw from a credit pool.\nGitHub Copilot # Autocomplete IDE extension Ubiquitous Copilot was the first mainstream AI coding tool and remains the most widely adopted. It runs as an extension in VS Code, JetBrains, and Neovim (I have it wired into my LazyVim setup). The core experience is inline autocomplete: you type, it predicts what comes next.\nWhat sets it apart: autocomplete speed. Copilot\u0026rsquo;s tab-completion is fast and context-aware enough that it often finishes your thought correctly. For boilerplate, test cases, and repetitive patterns, it saves keystrokes every minute.\nCopilot Chat and the cloud agent have layered more agentic capability on top. Autocomplete is still the core value. The Business plan now bundles the cloud agent for organization-wide policy control.\nWhere it shines:\nWriting code in your editor with fast inline suggestions Boilerplate and repetitive patterns (test scaffolding, CRUD operations) Codebases you know well where you just need to type faster Broad language support across practically every editor Where it\u0026rsquo;s weaker:\nContext understanding beyond the current file is still its weakest dimension Multi-file tasks need more manual orchestration than the agentic tools require Chat quality lags dedicated agent products for nuanced reasoning Will confidently suggest plausible-looking but incorrect code Cost (May 2026): GitHub split Copilot into five tiers.\nFree: limited completions Pro ($10/mo): includes $10 in AI Credits Pro+ ($39/mo): includes $39 in AI Credits, premium model access Business ($19/user/mo): adds the cloud agent and policy controls Enterprise: tied to GitHub Enterprise Cloud Heads up Starting June 1, 2026, all plans move to usage-based billing. Every request will draw from your monthly AI Credit allotment instead of counting against unlimited premium requests.\nWindsurf # Agentic IDE Cognition-owned Cascade Windsurf had a wild 2025. OpenAI tried to buy it for $3B; the deal collapsed. Google then paid roughly $2.4B to license its tech and hire away the CEO plus around 40 top engineers. In December 2025, Cognition AI (makers of Devin) acquired what remained for approximately $250M and started folding Devin\u0026rsquo;s autonomous agent capabilities into the product.\nThe product came out the other side stronger than the chaos suggested. As of early 2026, Windsurf was ranked #1 in the LogRocket AI Dev Tool Power Rankings (ahead of both Cursor and Copilot), driven by its Cascade agent flow and the tight loop between autonomous edits and human review. The Codemaps feature visualizes what the agent is touching across your codebase before it commits.\nWhere it shines:\nLong-running autonomous tasks where you want to step in only at decision points Enterprise teams attracted to the Devin integration story Visual project-level reasoning via Codemaps Where it\u0026rsquo;s weaker:\nBrand whiplash: the product has changed hands twice in a year, and the roadmap reflects that Pricing tightened in 2026; the previously generous free tier now hits paywalls fast Smaller community than Cursor or Copilot Cost: Free tier with limits, paid plans starting around $15/month. Enterprise pricing on request.\nHonorable Mentions # Other tools worth knowing about:\nv0 (by Vercel): Specialized for generating React/Next.js UI components from descriptions. Great for frontend prototyping; not a general coding tool. bolt.new: Browser-based full-stack app generator. Useful for quick demos, weaker on production codebases. Aider: Open-source terminal-based coding assistant. Less polished than Claude Code, fully customizable, strong community. Zed AI: Zed editor\u0026rsquo;s built-in AI features. Lightweight if you already live in Zed. Comparison Table # Feature Claude Code Cursor Copilot Windsurf Interface CLI + IDE + desktop + web VS Code fork IDE extension VS Code fork Primary mode Agent + conversation Inline edit + Agent Autocomplete + chat Agent (Cascade) Project context Full codebase Open files + indexing Current file + neighbors Full codebase + Codemaps Can run commands Yes Yes (Agent mode) Limited (cloud agent) Yes Plan before code Yes (Plan Mode) Partial No Yes (Cascade preview) Multi-file edits Yes (agentic) Yes (Composer) Manual Yes (Cascade) Extension model Skills/Plugins/MCP/Hooks Rules + MCP Limited MCP Best for Full-stack projects Focused refactoring Fast inline coding Long-running autonomy Learning curve Moderate Low Low Low-moderate Price API / Pro / Max $0 / $20 / $60 / $200 $0 / $10 / $39 / $19 (biz) $0 / ~$15+ My Recommendation: Different Tools for Different Jobs # No single \u0026ldquo;best\u0026rdquo; tool. The right one depends on the shape of the work.\nflowchart TD A[What are you doing?] --\u003e B{Scaffolding orcross-cutting work?} A --\u003e C{Focused refactoringin an editor?} A --\u003e D{Inline autocompletewhile typing?} A --\u003e E{Long autonomoustasks with check-ins?} B --\u003e|Yes| F[Claude Code] C --\u003e|Yes| G[Cursor] D --\u003e|Yes| H[Copilot] E --\u003e|Yes| I[Windsurf] F --\u003e J[Full-project reasoning,multi-step tasks, Skills/Plugins] G --\u003e K[Precise edits, visual diffs,Composer for multi-file] H --\u003e L[Fast completions,boilerplate, stay in editor] I --\u003e M[Cascade agent flow,Codemaps, Devin integration] The tools aren\u0026rsquo;t mutually exclusive. I run Copilot in Neovim for daily autocomplete. For scaffolding or complex migrations, I switch to Claude Code in a tmux pane. A VS Code-native engineer doing focused refactoring would reach for Cursor. Enterprise teams wanting long-running autonomous tasks with a review checkpoint at every step are the natural fit for Windsurf.\nWhat I Actually Use Daily # My daily stack:\nClaude Code for project-level work: new features, blog posts, CI changes, anything that touches multiple files or needs reasoning about the project structure. Skills and Subagents handle the workflows I run repeatedly. Copilot in Neovim for inline autocomplete while writing code in my editor. Neither for architectural decisions, debugging subtle issues, or anything requiring deep domain knowledge. Faster to think through those myself. The best AI coding tool is whichever one fits your existing workflow without forcing a new one. Live in the terminal? Claude Code feels native. Live in VS Code? Cursor won\u0026rsquo;t disrupt your flow. Want faster typing? Copilot is the lowest-friction option. Want autonomous long-running work with checkpoints? Windsurf is built for that shape of task. The tools will keep blurring into each other. By 2027 I expect Cursor\u0026rsquo;s Agent mode, Windsurf\u0026rsquo;s Cascade, and Claude Code\u0026rsquo;s harness to look much more similar than they do today. What matters is the paradigm (agentic, inline, autocomplete), not the feature list that looks longest on a marketing page. Pick the one that matches how you actually work.\nFor a real-world look at how I use Claude Code on an actual project, see my vibe coding workflow post. For the bigger picture on what this means for engineering as a profession, read why vibe coding amplifies senior engineers.\n","date":"10 May 2026","externalUrl":null,"permalink":"/posts/vibe-coding-tools-compared/","section":"Posts","summary":" The AI coding tool landscape in 2026 has finally settled into four serious players: Claude Code , Cursor , GitHub Copilot , and Windsurf . I've used all four on real work. This is the honest comparison. Forget feature checklists. What matters is how each tool feels under real engineering work, the kind I do every day as a senior software engineer at Meta. I built this site primarily with Claude Code, but I’ve put serious hours into the others.\n","title":"Claude Code vs Cursor vs Copilot vs Windsurf: An Honest 2026 Comparison","type":"posts"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/claudedeck/","section":"Tags","summary":"","title":"Claudedeck","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/codesign/","section":"Tags","summary":"","title":"Codesign","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/daemon/","section":"Tags","summary":"","title":"Daemon","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/data-structures/","section":"Tags","summary":"","title":"Data-Structures","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/elgato/","section":"Tags","summary":"","title":"Elgato","type":"tags"},{"content":" A Stream Deck has five session keys. I usually have six or seven Claude Code sessions running. When a new one shows up, the muscle memory test isn't \"does the right session get evicted\", it is \"do the four survivors stay on the keys they were already on.\" Building ClaudeDeck · Part 3 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Two bits of context for anyone new to the stack: Stream Deck is Elgato\u0026rsquo;s USB grid of programmable LCD keys, and a \u0026ldquo;session\u0026rdquo; here is a single Claude Code conversation: claude running in one terminal tab, with its own working directory, its own context window, its own history. LRU stands for \u0026ldquo;least-recently used,\u0026rdquo; the standard cache-eviction policy: when you need to make room, drop the entry nobody has touched in the longest time.)\nAfter I\u0026rsquo;d worked through the Stream Deck SDK quirks and finally had keys repainting reliably, this is the puzzle I hit next. It looks small until you try to write it.\nThe constraint # Five physical keys. MAX_SESSIONS = 5 (in daemon/src/types.ts). Each key shows the working directory and state of one Claude Code session. Open a sixth session and one of the existing slots has to go.\nTwo rules, and they fight each other:\nEviction is LRU. The session I haven\u0026rsquo;t touched in the longest time is the one I care about least. Drop that one. Surviving keys do not move. If session B was on key 2 before the eviction, it is still on key 2 after. My finger already knows where it lives. Pure LRU breaks rule 2: the canonical implementation moves the touched entry to the front of a linked list, so the visible order changes every time any session updates. Pure FIFO satisfies rule 2 but breaks rule 1: it evicts whichever session started earliest, regardless of whether I just used it.\nYou can do both at once if you stop thinking of \u0026ldquo;order of eviction\u0026rdquo; and \u0026ldquo;order on screen\u0026rdquo; as the same order.\nWhy the obvious approaches fail # The first thing I reached for was a hand-rolled doubly-linked list, a proper LRU cache. (Doubly-linked because each node points at both its previous and next neighbours, so splicing a node out and pasting it in elsewhere is O(1): three pointer updates.) The most-recently-used end is the head; the least-recently-used is the tail; on touch you splice the node to the head; on eviction you drop the tail. Textbook.\nThe problem is that \u0026ldquo;splice to the head\u0026rdquo; is exactly the operation I need to never happen. Every time the daemon ships me a session:update (every assistant turn, every state flip from idle to thinking), the touched session bubbles to position 0 and every other slot index shifts. From the Stream Deck\u0026rsquo;s perspective, the keys rearrange under my fingers between turns. Unusable.\nThe second thing I reached for was lru-cache off npm. Same problem in nicer packaging: it\u0026rsquo;s designed to maximize cache-hit rate by making recent entries cheaper to find, and it sorts by recency internally.\nWhat I actually want is more like a fixed-size circular buffer that happens to know how to pick a victim. The slot a session occupies on the Stream Deck should be determined by when the session first appeared, not by when it was last touched. The lastActivityAt field is only consulted at eviction time.\nThe primitive that solves it # JavaScript\u0026rsquo;s Map preserves insertion order. (Map is the built-in key-value collection, like Object for general lookups, but with any-type keys and a guaranteed iteration order. Unlike a plain {}, iterating a Map gives you entries back in the order you inserted them.) That\u0026rsquo;s not a coincidence or an implementation detail, it\u0026rsquo;s in the spec, and every engine has implemented it that way since ES2015. Iterate .keys(), .values(), or .entries() and you get items back in the order they were first set().\nThe key subtlety: updating an existing key does not change its position. map.set(\u0026quot;a\u0026quot;, 1); map.set(\u0026quot;b\u0026quot;, 2); map.set(\u0026quot;a\u0026quot;, 99); gives you [\u0026quot;a\u0026quot;, \u0026quot;b\u0026quot;] on iteration, not [\u0026quot;b\u0026quot;, \u0026quot;a\u0026quot;]. The position is fixed at first insertion. To move a key to the back, you have to delete it first and then set it again, a pattern the spec leaves available precisely so you can build an LRU on top.\nSo one Map gives me two views for free:\nLayout view: iterate it. Items come back in insertion order, which is also the order they appeared on the Stream Deck. Eviction view: scan the values for the minimum lastActivityAt. That\u0026rsquo;s the LRU pick. The thing that bridges them is a discipline: on update, set the value without touching the key\u0026rsquo;s position.\nThe implementation # The whole thing lives in plugin/src/sessionSlots.ts. It\u0026rsquo;s about 120 lines. Here\u0026rsquo;s the part that matters:\nexport class SessionSlotManager extends EventEmitter { private readonly slots = new Map\u0026lt;string, SessionSnapshot\u0026gt;(); private readonly now: () =\u0026gt; number; private readonly cap: number; upsert(session: SessionSnapshot): void { const existing = this.slots.get(session.sessionId); if (existing) { this.slots.set(session.sessionId, session); return; } if (this.slots.size \u0026gt;= this.cap) { this.evictOldest(); } this.slots.set(session.sessionId, session); } That if (existing) { set; return; } is doing a lot of work. It\u0026rsquo;s the entire reason rule 2 holds. The set writes a new SessionSnapshot (with a fresh lastActivityAt) into the existing slot. Because the key is already in the Map, its insertion-order position doesn\u0026rsquo;t budge. The visible layout is stable; the recency data updates underneath.\nCompare with the daemon-side store in daemon/src/stateStore.ts, which does the opposite on register:\nif (existing) { existing.lastActivityAt = t; this.sessions.delete(input.sessionId); this.sessions.set(input.sessionId, existing); // ... } That delete + set is the classic Map-based LRU move: it bumps the re-registered session to the back of the iteration order. The daemon wants that, because the daemon doesn\u0026rsquo;t render anything, it just needs the iteration order to track recency so its own eviction is trivial. The plugin doesn\u0026rsquo;t, because the plugin renders to physical keys.\nSame data structure, two opposite update disciplines, because they have two different jobs.\nSlot lookup is then just walking the Map:\nslotOf(sessionId: string): number | undefined { let i = 0; for (const id of this.slots.keys()) { if (id === sessionId) return i; i++; } return undefined; } O(N) where N ≤ 5. (Big-O on a five-entry collection is a punchline, not a complexity argument: the constant-factor cost of not introducing a second data structure to keep in sync is much larger than the cost of scanning five entries.) I considered keeping a parallel Map\u0026lt;sessionId, slotIndex\u0026gt; for O(1), then remembered N is five. Five.\nEviction is the other linear scan:\nprivate evictOldest(): void { let oldestId: string | undefined; let oldestAt = Number.POSITIVE_INFINITY; void this.now(); for (const s of this.slots.values()) { if (s.lastActivityAt \u0026lt; oldestAt) { oldestAt = s.lastActivityAt; oldestId = s.sessionId; } } if (oldestId !== undefined) { this.slots.delete(oldestId); this.emit(\u0026#34;evicted\u0026#34;, oldestId); } } When the sixth session arrives, this finds the entry with the smallest lastActivityAt (the LRU pick) and deletes it. The eviction emits an \u0026quot;evicted\u0026quot; event so the plugin can clear the vacated key (see plugin/src/plugin.ts, line 531). The four survivors\u0026rsquo; positions in the Map are unchanged, so their slot indices are unchanged, so my finger still knows where they live.\nThe one slot that does shift is the vacated one. If session A was at key 2 and gets evicted, the new session lands at key 4 (the now-empty tail), and the keys at indices 3 and 4 from before become indices 2 and 3, because they were inserted later than A. That\u0026rsquo;s the trade. You can\u0026rsquo;t have both stable indices and contiguous filling unless you track empty slots explicitly, and contiguous filling won.\nWhat the tests pin down # The test file (plugin/src/sessionSlots.test.ts) exists mostly to lock in the two non-obvious behaviors:\ntest(\u0026#34;upsert of existing session updates in place without changing its slot\u0026#34;, () =\u0026gt; { const mgr = makeMgr(); mgr.upsert(makeSnap(\u0026#34;s1\u0026#34;, 100)); mgr.upsert(makeSnap(\u0026#34;s2\u0026#34;, 110)); mgr.upsert(makeSnap(\u0026#34;s3\u0026#34;, 120)); mgr.upsert(makeSnap(\u0026#34;s2\u0026#34;, 200, { state: \u0026#34;thinking\u0026#34; })); expect(mgr.slotOf(\u0026#34;s2\u0026#34;)).toBe(1); // ← rule 2 expect(mgr.sessionAt(1)?.state).toBe(\u0026#34;thinking\u0026#34;); }); test(\u0026#34;updating an existing session refreshes its lastActivityAt so it survives eviction\u0026#34;, () =\u0026gt; { const mgr = makeMgr(); for (let i = 0; i \u0026lt; 5; i++) mgr.upsert(makeSnap(`s${i}`, 100 + i * 10)); mgr.upsert(makeSnap(\u0026#34;s0\u0026#34;, 999)); // touch the oldest mgr.upsert(makeSnap(\u0026#34;s5\u0026#34;, 1000)); expect(mgr.slotOf(\u0026#34;s0\u0026#34;)).toBeDefined(); // ← rule 1 expect(mgr.slotOf(\u0026#34;s1\u0026#34;)).toBeUndefined(); }); If anyone ever switches upsert to the daemon\u0026rsquo;s delete-then-set pattern thinking it\u0026rsquo;s more \u0026ldquo;correct\u0026rdquo;, the first test catches it. If anyone tries to make eviction iteration-order-based (\u0026ldquo;the first one inserted is the oldest\u0026rdquo;), the second test catches it.\nLessons # JavaScript\u0026rsquo;s Map preserves insertion order, and that\u0026rsquo;s a primitive you almost never need to fight. When you want LRU behavior on a Map, delete+set moves an entry to the back. When you don\u0026rsquo;t, set leaves it where it is. Two opposite disciplines, same data structure.\nLRU and stable layout look like they conflict. They don\u0026rsquo;t, if you separate \u0026ldquo;which entry to evict\u0026rdquo; from \u0026ldquo;where each entry sits.\u0026rdquo; Eviction reads lastActivityAt. Layout reads iteration order. Decouple them and both rules hold.\nDon\u0026rsquo;t reach for lru-cache when N is five. The package solves a problem you don\u0026rsquo;t have (hot-path cache-hit performance) and prevents the trick you need (decoupling recency from position). A linear scan over five entries is faster than the call to load the dependency.\nA test that pins down the boring property is worth more than ten tests of the interesting one. \u0026ldquo;Updates don\u0026rsquo;t move slots\u0026rdquo; is the property a future me will accidentally break. The eviction logic is obvious enough that it survives refactors; the update-in-place discipline doesn\u0026rsquo;t.\nReferences # Plugin-side slot manager (the post\u0026rsquo;s subject): plugin/src/sessionSlots.ts Plugin-side tests: plugin/src/sessionSlots.test.ts Daemon-side store with the opposite (delete-then-set) discipline: daemon/src/stateStore.ts Eviction listener that triggers a key repaint: plugin/src/plugin.ts#L531 MAX_SESSIONS definition: daemon/src/types.ts MDN, Map insertion-order iteration: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map ","date":"10 May 2026","externalUrl":null,"permalink":"/posts/lru-session-eviction/","section":"Posts","summary":" A Stream Deck has five session keys. I usually have six or seven Claude Code sessions running. When a new one shows up, the muscle memory test isn't \"does the right session get evicted\", it is \"do the four survivors stay on the keys they were already on.\" Building ClaudeDeck · Part 3 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Two bits of context for anyone new to the stack: Stream Deck is Elgato’s USB grid of programmable LCD keys, and a “session” here is a single Claude Code conversation: claude running in one terminal tab, with its own working directory, its own context window, its own history. LRU stands for “least-recently used,” the standard cache-eviction policy: when you need to make room, drop the entry nobody has touched in the longest time.)\n","title":"Five Stream Deck keys, N Claude sessions: LRU that keeps the order I see","type":"posts"},{"content":" Claude Code wants to run a shell command. I want to press a physical Stream Deck key, the YES key two inches to the left of my keyboard, to approve it. The hook gets exactly one HTTP response to decide allow vs deny. The key press might land in 200 milliseconds; it might land seven minutes later, after I've been pulled into a meeting and come back. The trick is that Claude Code's hook timeout is 600 seconds, which turns out to be just enough headroom to hold the HTTP response open the whole time and let a hardware button write the answer. Building ClaudeDeck · Part 6 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Setup, for anyone who hasn\u0026rsquo;t seen this stack before: Claude Code is Anthropic\u0026rsquo;s terminal CLI for Claude, and one of its hook events, PreToolUse, is a script Claude spawns and waits on before running a tool like Bash or Edit. The script\u0026rsquo;s stdout decides \u0026ldquo;allow\u0026rdquo; / \u0026ldquo;deny\u0026rdquo; / \u0026ldquo;ask\u0026rdquo;. Stream Deck is Elgato\u0026rsquo;s USB grid of programmable LCD keys. The plumbing I\u0026rsquo;m describing here lives in a daemon, a background process at 127.0.0.1:9127, that the hook script POSTs to and that the Stream Deck plugin connects to over WebSocket. For the hooks docs themselves and the four other gotchas in that layer, see the hooks-reality post.)\nUpdate (2026-05-10): The architecture in this post was the v1 design. The current ClaudeDeck daemon has shifted to a fire-and-forget hook + PTY-keystroke-injection model. PTY (pseudo-terminal, the kernel object behind every interactive shell, the pair of file descriptors that lets one process pretend to be a keyboard typing at another) ring-buffer interactions made hold-open unreliable in long sessions, and routing the approval through the same TTY (the controlling terminal Claude is reading stdin from) removed a class of bugs. The lessons below still apply if you\u0026rsquo;re designing a sync→async permission gate from scratch, but the live daemon/src/server.ts no longer holds the response open. See the PTY-wrap follow-up post for why the migration happened. This post is about one feature in ClaudeDeck: when Claude Code wants to run a tool, my Stream Deck lights up YES / NO / ALWAYS keys. I press one. Claude proceeds. It works, but the design took several iterations and there are still rough edges I\u0026rsquo;m chewing on.\nThe YES / NO / ALL keys in the middle-left of that grid are the ones this post is about: the hardware buttons whose physical press has to make it back through a held-open HTTP response in time for Claude Code to receive a decision.\nThe shape of the gate # Claude Code\u0026rsquo;s PreToolUse hook is a synchronous gate. Claude pauses, runs your hook command, and reads its stdout (or exit code) to decide whether the tool runs. The relevant stdout shape:\n{ \u0026#34;hookSpecificOutput\u0026#34;: { \u0026#34;hookEventName\u0026#34;: \u0026#34;PreToolUse\u0026#34;, \u0026#34;permissionDecision\u0026#34;: \u0026#34;allow\u0026#34; } } permissionDecision is one of allow, deny, or ask (the last falls through to Claude\u0026rsquo;s built-in terminal prompt). The hook\u0026rsquo;s per-call timeout is 600 seconds: Claude waits up to ten minutes for your hook to print JSON and exit. I\u0026rsquo;ll come back to that number; it\u0026rsquo;s the entire reason this design works.\nWithout that ceiling, there\u0026rsquo;d be no point pausing for human input. The hook would have to return immediately and the gate would degenerate into \u0026ldquo;auto-allow everything\u0026rdquo;.\nThe daemon: hold the HTTP response # The hook command is a small shell script that POSTs the hook payload to my daemon and waits for the response body:\n#!/bin/bash curl -sf -X POST \u0026#34;http://127.0.0.1:9127/hooks/PreToolUse\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ --max-time 590 \\ -d @- \u0026lt;\u0026lt;\u0026lt; \u0026#34;$(cat)\u0026#34; 590s, not 600s, so we have a 10s safety margin under Claude\u0026rsquo;s own ceiling. (More on why that margin matters when we get to the race conditions.)\nOn the daemon side, the POST /hooks/PreToolUse handler does this:\nParse the hook payload. Extract the session ID. Check: is at least one Stream Deck plugin connected via WebSocket? (sockets.size \u0026gt; 0) If no plugin is connected, return {\u0026quot;hookSpecificOutput\u0026quot;:{\u0026quot;permissionDecision\u0026quot;:\u0026quot;ask\u0026quot;}} immediately. Claude falls back to its terminal prompt. If a plugin is connected, register a pending permission keyed by session ID, broadcast permission:pending over WebSocket to all subscribed plugins, then hold the HTTP response open waiting for the resolution. When permission:respond arrives from the plugin (the user pressed YES/NO/ALWAYS), look up the pending permission, write the decision into the still-open HTTP response, close. If the 590s timer fires first, write an empty body. Claude sees no JSON, falls through to whatever its default behavior is for the active permission rules. The hold-open is implemented as a Promise (JavaScript\u0026rsquo;s deferred-value primitive, a placeholder you can await on, that some other code path resolves with the answer when it\u0026rsquo;s ready) that resolves when the WebSocket handler delivers a verdict:\nasync function holdForPermission( sessionId: string, capMs: number, ): Promise\u0026lt;PermissionDecision | \u0026#34;timeout\u0026#34;\u0026gt; { return new Promise((resolve) =\u0026gt; { const pending = { resolve, timer: setTimeout(() =\u0026gt; { pendingPermissions.delete(sessionId); resolve(\u0026#34;timeout\u0026#34;); }, capMs) }; pendingPermissions.set(sessionId, pending); }); } // Resolved by the WebSocket handler when the plugin sends permission:respond. function resolvePending(sessionId: string, decision: PermissionDecision) { const pending = pendingPermissions.get(sessionId); if (!pending) return; clearTimeout(pending.timer); pendingPermissions.delete(sessionId); pending.resolve(decision); } In the HTTP handler:\nconst decision = await holdForPermission(sessionId, 590_000); if (decision === \u0026#34;timeout\u0026#34;) { return new Response(\u0026#39;{\u0026#34;hookSpecificOutput\u0026#34;:{\u0026#34;permissionDecision\u0026#34;:\u0026#34;ask\u0026#34;}}\u0026#39;, { status: 200 }); } return new Response(JSON.stringify({ hookSpecificOutput: { hookEventName: \u0026#34;PreToolUse\u0026#34;, permissionDecision: decision } }), { status: 200 }); No queues, no message brokers, no Redis (the in-memory key-value store people typically reach for when they want fast cross-process messaging). The hold-open Promise is the queue. The HTTP response is the channel.\nPicking the timeout # I shipped 5 seconds first. Placeholder while I built the plumbing. Even before I tested with another human, the number broke: I\u0026rsquo;d be sitting in front of the Stream Deck, Claude would want to run Edit, I\u0026rsquo;d be reading the diff Claude printed beforehand, and 5 seconds in the hook had already timed out and Claude had fallen through to the terminal prompt.\nThen 20s. Better. Still too short for any tool call where I wanted to read the input first (anything that touches a file, anything that runs a command I haven\u0026rsquo;t reviewed). Missed presses constantly.\nThen 60s. Comfortable for routine tool calls. Still too short when Slack pulled me out of the loop mid-turn and I came back to find the gate gone.\nThen 300s. Comfortable for everything except long context switches. But the long context switches (pulled into a meeting, walked to coffee, came back 8 minutes later) are the cases that hurt most, because the response is then \u0026ldquo;Claude waited 5 minutes for me, gave up, and I have no idea what state we\u0026rsquo;re in\u0026rdquo;.\nFinally 590s. Claude\u0026rsquo;s own 600s ceiling minus a 10s safety margin. It\u0026rsquo;s the maximum hold I can offer without risking Claude timing out before the daemon does and ending in a weird \u0026ldquo;decision arrived but Claude already gave up\u0026rdquo; race. I haven\u0026rsquo;t found a case where 590s feels too short.\nThe lesson here ended up being smaller than I expected and also more general: size for the maximum reasonable human latency, not the median. The median is forgiving. Two seconds, five seconds, twenty seconds: they all \u0026ldquo;work\u0026rdquo; for the median. What breaks is the max. Optimizing for the max means the median works fine and you have headroom. Optimizing for the median means the max case is permanently broken.\nThe gating-everything problem # A design tension I haven\u0026rsquo;t fully resolved.\nClaude Code has multiple permission modes: default, accept-edits, bypassPermissions. In default, Claude prompts before running tools. In accept-edits, Claude auto-allows edit-related tools but still prompts for shells. In bypassPermissions, Claude auto-allows everything.\nMy PreToolUse hook fires regardless of permission mode. The hook payload doesn\u0026rsquo;t include the current mode. So the daemon can\u0026rsquo;t see whether Claude would have auto-allowed this tool call. The user is stuck pressing YES on every single tool, including ones Claude was going to allow anyway.\nIn bypassPermissions mode, pressing YES 200 times per session is friction the user shouldn\u0026rsquo;t have to put up with. But I can\u0026rsquo;t conditionalize the hook on the mode, because the mode isn\u0026rsquo;t in the payload.\nI have four candidate fixes. They\u0026rsquo;re worth comparing side-by-side rather than wrapping in prose, because the right answer is \u0026ldquo;some combination\u0026rdquo;:\nOption A: Parse permissions.allow rules from settings. Daemon reads ~/.claude/settings.local.json (and the project-local copy), parses the same patterns Claude uses, matches the tool call against them. If the rule would allow, return immediately, no hold. Most accurate. Most code to write. Has to fsWatch the file so rule changes take effect without a daemon restart. Option B: A Stream Deck \u0026ldquo;mode\u0026rdquo; key that mirrors Claude\u0026rsquo;s permission mode. User toggles it manually between GATE / AUTO / SMART. Daemon branches on the toggle. Cheap to build. Requires user discipline: if the Stream Deck mode and Claude\u0026rsquo;s actual mode drift, the user gets surprised. Option C: A hardcoded allow-list of \u0026ldquo;always safe\u0026rdquo; tools (Read, Glob, LS, Task*). Pragmatic, ~30 minutes of work, ~80% of the noise gone. Misses edge cases: short read-only Bash calls, MCP tools the user trusts. Option D: All of the above. Hardcoded list for the common case. Allowlist parsing for full coverage. Mode toggle for explicit override. I\u0026rsquo;m leaning toward C as the starting point and graduating to D over time. The full design notes live in docs/2026-04-22-gating-behavior.md in the repo.\nThe \u0026ldquo;plugin connected but device unplugged\u0026rdquo; gotcha # This one I caught the first time my Stream Deck cable got snagged on my chair.\nIf the physical Stream Deck device is unplugged but the Stream Deck app is still running, the daemon\u0026rsquo;s sockets.size \u0026gt; 0 check still returns true. The plugin\u0026rsquo;s WebSocket is still alive, but it can\u0026rsquo;t reach the device anymore. The daemon dutifully holds the hook for 590 seconds waiting for a key press that physically cannot arrive.\nTwo ways out:\nUser workaround. Quit Stream Deck app or run streamdeck stop com.nickboy.claudedeck to kill the WebSocket subscriber. Daemon goes to fire-and-forget; Claude proceeds via its own allow-list. Plugin-side fix. Listen for onDeviceDidDisconnect from the SDK and notify the daemon. Then the daemon\u0026rsquo;s \u0026ldquo;plugin connected\u0026rdquo; check becomes \u0026ldquo;plugin connected AND device present\u0026rdquo;. Not shipped yet. This is the kind of edge case that\u0026rsquo;s invisible until it bites you. Worth saying once: if you\u0026rsquo;re holding open an HTTP request waiting on a hardware action, you need a story for \u0026ldquo;the hardware physically isn\u0026rsquo;t there anymore\u0026rdquo;. A connected WebSocket is not the same thing as a reachable hand.\nDiagnosing a press that didn\u0026rsquo;t make it # When a Stream Deck press doesn\u0026rsquo;t seem to reach Claude, the chain has five steps and three logs to cross-reference. The decision tree, which lives in the repo\u0026rsquo;s docs/2026-04-21-yes-button-bug.md:\n1. claude --debug hooks --debug-file /tmp/claude-hooks.log 2. ~/.claudedeck/daemon.log 3. ~/.claudedeck/plugin.log Plugin log on press | Daemon log on press | Suspect ---------------------------|---------------------------|--------------------------- target=none | (no new ws-cmd line) | Wrong session focused target=abc... | (no new ws-cmd line) | Plugin WS disconnected target=abc... | hasPending=false | Pending already cleared target=abc... | hasPending=true | Chain is correct; check | | settings.local.json\u0026#39;s | | permissions.ask rule Each row corresponds to a different broken link (focus / WS / state / settings). The triangulation tells you where to look without making you guess. Single-log debugging on a four-process pipeline is mostly vibes; three-log debugging actually narrows it down.\nThe last row in particular caught me twice. Claude Code auto-writes ask rules into settings.local.json on first denial, and ask rules silently override hook \u0026quot;allow\u0026quot; decisions. The Stream Deck press lands, the daemon happily writes permissionDecision: \u0026quot;allow\u0026quot; into the held response, Claude reads it\u0026hellip; and then prompts in the terminal anyway because the ask rule fires before the hook decision is consulted. From the user\u0026rsquo;s seat: \u0026ldquo;I pressed YES, the daemon log says it worked, why is Claude still asking?\u0026rdquo;. The answer is in the settings file.\nLessons # Holding HTTP requests is a viable IPC pattern when one side is single-purpose and the other has a generous timeout. No queues, no brokers: the Promise is the queue, the HTTP response is the channel. The pattern lives or dies on the timeout headroom. Size the hold for the maximum tolerable human latency, not the median. Median latency is forgiving; max latency is what breaks the experience. Anything else is optimizing for the case that already works. Always have a graceful fall-through when the gate can\u0026rsquo;t render. No plugin connected? Return ask immediately so Claude can use its own prompt. Never silently block on a UI that isn\u0026rsquo;t there. The hook payload doesn\u0026rsquo;t include enough state to be context-aware. Permission mode, allow-list rules, current focus: none of it\u0026rsquo;s in the payload. Anything mode-aware has to be reconstructed daemon-side, which is fragile and probably wrong half the time. Three logs beat one when the pipeline crosses process boundaries. Single-log debugging is guesswork; cross-referenced logs tell you which link in the chain broke. The full design notes and the related yes-button-bug post-mortem are in docs/2026-04-21-yes-button-bug.md and docs/2026-04-22-gating-behavior.md in the ClaudeDeck repo if you want the raw debugging notes, including the architecture I considered (and rejected) of writing the keystroke into Claude\u0026rsquo;s PTY instead of returning JSON through the hook.\nFor the companion post on how the hook payload actually reaches the daemon in the first place (stdin, not env var; the docs are misleading), see How Claude Code hooks actually work.\nReferences # Claude Code hooks reference (PreToolUse event, hookSpecificOutput schema, permissionDecision values): https://docs.claude.com/en/docs/claude-code/hooks The 600-second default per-hook timeout is referenced in the same docs page. ClaudeDeck\u0026rsquo;s 590s --max-time is a 10s safety margin under that ceiling, set after measuring that the daemon\u0026rsquo;s own timer needed to fire strictly before Claude\u0026rsquo;s to avoid a \u0026ldquo;decision arrived but Claude already gave up\u0026rdquo; race. AgentDeck, the upstream agent-runtime project that pioneered the HTTP-hold-for-permission-input pattern that ClaudeDeck adopted: https://github.com/puritysb/AgentDeck/tree/master/bridge ClaudeDeck\u0026rsquo;s permission hold-open implementation: daemon/src/server.ts (POST /hooks/PreToolUse handler, holdForPermission, and the responseToKeystroke mapping if you want the keystroke-injection variant). Debugging decision tree for \u0026ldquo;press didn\u0026rsquo;t reach Claude\u0026rdquo;: docs/2026-04-21-yes-button-bug.md Four candidate fixes for the gate-everything problem: docs/2026-04-22-gating-behavior.md Companion post: How Claude Code hooks actually work ","date":"10 May 2026","externalUrl":null,"permalink":"/posts/permission-round-trip/","section":"Posts","summary":" Claude Code wants to run a shell command. I want to press a physical Stream Deck key, the YES key two inches to the left of my keyboard, to approve it. The hook gets exactly one HTTP response to decide allow vs deny. The key press might land in 200 milliseconds; it might land seven minutes later, after I've been pulled into a meeting and come back. The trick is that Claude Code's hook timeout is 600 seconds, which turns out to be just enough headroom to hold the HTTP response open the whole time and let a hardware button write the answer. Building ClaudeDeck · Part 6 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Setup, for anyone who hasn’t seen this stack before: Claude Code is Anthropic’s terminal CLI for Claude, and one of its hook events, PreToolUse, is a script Claude spawns and waits on before running a tool like Bash or Edit. The script’s stdout decides “allow” / “deny” / “ask”. Stream Deck is Elgato’s USB grid of programmable LCD keys. The plumbing I’m describing here lives in a daemon, a background process at 127.0.0.1:9127, that the hook script POSTs to and that the Stream Deck plugin connects to over WebSocket. For the hooks docs themselves and the four other gotchas in that layer, see the hooks-reality post.)\n","title":"Holding HTTP open for 590 seconds so a Stream Deck key can approve a tool call","type":"posts"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/hotkey/","section":"Tags","summary":"","title":"Hotkey","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/http/","section":"Tags","summary":"","title":"Http","type":"tags"},{"content":" My daemon logged 111 consecutive HTTP 429s against `https://api.anthropic.com/api/oauth/usage` over an 18-hour stretch, with zero successful responses ever in its lifetime. The poller was reading `Retry-After: 272` and ignoring it. While I was arguing with the backoff, Claude Code was pushing the same `rate_limits.five_hour` and `rate_limits.seven_day` numbers to my statusline command every turn, on stdin, for free. Building ClaudeDeck · Part 5 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Quick framing: Claude Code is Anthropic\u0026rsquo;s terminal CLI for Claude; Claude Max is the higher-tier subscription plan with weekly and 5-hour usage windows. HTTP 429 is \u0026ldquo;Too Many Requests\u0026rdquo;, the server\u0026rsquo;s polite way of saying \u0026ldquo;back off.\u0026rdquo; Retry-After is the response header that tells the client how long to wait. OAuth is the auth protocol Claude Code uses to talk to Anthropic on behalf of a logged-in user. And the statusline (the same one I covered in the statusline side-channel post) is the script Claude Code spawns every turn with a JSON blob on stdin.)\nI built the wrong thing. Not in the \u0026ldquo;shipped it and it was slow\u0026rdquo; sense, but in the \u0026ldquo;the API I was polling was never meant to be polled\u0026rdquo; sense. The piece of the system I needed had been sitting on /dev/stdin of a script I\u0026rsquo;d already installed, on every Claude turn, for the whole 18 hours I was watching the daemon log fill up with 429s.\nThis is the post-mortem.\nSymptom: a banlist that never thawed # ClaudeDeck has a Stream Deck key that shows my Claude Max plan usage: the 5-hour block and the 7-day weekly window. Two percentages, a small countdown to the next reset, color-graded so I can tell from across the room whether I\u0026rsquo;m about to get rate-limited mid-flow.\nThe first version was a polling loop. Every 60 seconds, the daemon hit https://api.anthropic.com/api/oauth/usage with the same OAuth token Claude Code uses, parsed the response, and pushed the numbers over WebSocket to the Stream Deck plugin. Standard background-poller shape.\nIt never worked. Not once. Over 18 hours and change, the log line plan:error reason=429 repeated 111 times and plan:update never appeared. The token wasn\u0026rsquo;t expired (an expired token would be 401, not 429). The cadence was getting hammered by MAX_BACKOFF_MS = 10 * 60_000, which meant the worst case was one request per ten minutes, call it ~108 requests per ban window. Every single one a 429.\nWhen I finally curled the endpoint by hand to see what was coming back, Anthropic was being polite about it (those cf-ray/server: cloudflare headers mean the request is being rejected at Cloudflare\u0026rsquo;s edge, not the origin; Anthropic puts Cloudflare in front of the API):\nHTTP/2 429 date: Mon, 11 May 2026 01:55:01 GMT retry-after: 272 content-type: application/json cf-ray: 9f9d8f1e6db975ec-SEA server: cloudflare {\u0026#34;error\u0026#34;: {\u0026#34;type\u0026#34;: \u0026#34;rate_limit_error\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;Rate limited. Please try again later.\u0026#34;}} Retry-After: 272. Wait four and a half minutes, try again. My poller wasn\u0026rsquo;t reading that header at all. The error path in daemon/src/claudeAiFetcher.ts was returning a string, discarding the whole Response, and the backoff math in planUsagePoller.ts was running on its own 2^N schedule decoupled from what Anthropic was actually asking.\nSo I sat down to write a parseRetryAfter function. RFC 9110 (the current HTTP/1.1 semantics spec, which defines what a header like Retry-After actually means) says the header is either delta-seconds (an integer like 272) or an HTTP-date (a full timestamp like Wed, 21 Oct 2026 07:28:00 GMT); both shapes need to work. A kind: \u0026quot;rate_limited\u0026quot;; retryAfterMs variant for the fetcher to return. New tests for the integer path and the IMF-fixdate path (IMF-fixdate is the RFC 9110 name for the fixed-width \u0026ldquo;day, DD Mon YYYY HH:MM:SS GMT\u0026rdquo; date format, the only date shape modern HTTP allows) and the \u0026ldquo;garbage header\u0026rdquo; fallback. Wire updateCadence to honor it. Maybe two or three hours of careful work.\nThis was the wrong fix. None of it would have helped.\nInvestigation: what was I even polling? # Before I shipped a careful Retry-After parser, I checked whether the endpoint I was being so careful with was actually one I was supposed to be hitting.\n/api/oauth/usage is not in Anthropic\u0026rsquo;s public REST API docs. It\u0026rsquo;s not in the Messages API (Anthropic\u0026rsquo;s main public endpoint, the one SDKs and most apps use), the OAuth flow docs, the workspace billing endpoints, none of them. The community knows about it because claude.ai\u0026rsquo;s web UI hits it to populate the little usage panel (you can confirm this yourself by opening DevTools\u0026rsquo; Network tab on claude.ai). A handful of third-party tools (AgentDeck\u0026rsquo;s bridge, ohugonnot\u0026rsquo;s claude-code-statusline shell script, jens-duttke\u0026rsquo;s usage-monitor-for-claude) poll it from background processes, with care.\nHow much care? AgentDeck\u0026rsquo;s bridge/src/usage-api.ts runs a 120-second file-cache TTL, with an explicit comment saying it was raised from 60 seconds \u0026ldquo;to avoid 429\u0026rdquo;. ohugonnot\u0026rsquo;s tool defaults to 300 seconds and warns against setting it lower. jens-duttke\u0026rsquo;s tool offers 5-minute, 15-minute, and 30-minute presets and refuses to let you pick less. Industry consensus on the endpoint, among the three independent tools polling it, was somewhere between 2 and 5 minutes. Mine was hitting it at 60.\nThat fixed the obvious problem (the poller was 2-5× too aggressive) but didn\u0026rsquo;t explain the 100% 429 rate. Sixty seconds is loud, not radioactive. Three other repos were polling at similar cadences without continuous bans.\nThen I went looking at the issue tracker. Specifically:\nanthropics/claude-code #31021: closed not planned. anthropics/claude-code #31637: closed invalid. Reports 30+ minute bans against this endpoint. anthropics/claude-code #30930: Retry-After: 0 bug, also closed without a fix. Three separate issues against this endpoint, three \u0026ldquo;not our problem\u0026rdquo; closures. That\u0026rsquo;s not a triage backlog. That\u0026rsquo;s a signal: the surface isn\u0026rsquo;t being supported. It\u0026rsquo;s the URL claude.ai\u0026rsquo;s React app hits on page load to show you the little panel, and Anthropic is fine if it occasionally falls over because the web UI\u0026rsquo;s failure mode is \u0026ldquo;panel is blank for thirty seconds.\u0026rdquo; A daemon doing 60-second polls is not a use case they care to keep working.\nThe poller wasn\u0026rsquo;t broken. The endpoint was the wrong primitive.\nRoot cause: the data was already pushed, on stdin, every turn # While I was reading those closed issues, I tabbed over to Claude Code\u0026rsquo;s statusline docs for an unrelated reason (I was already using the statusline command as a per-turn telemetry side channel for context-window data) and noticed a field I\u0026rsquo;d never registered before.\nThe JSON Claude Code pipes to your statusline command\u0026rsquo;s stdin, per turn, includes this:\n{ \u0026#34;session_id\u0026#34;: \u0026#34;01J9...\u0026#34;, \u0026#34;model\u0026#34;: { \u0026#34;id\u0026#34;: \u0026#34;claude-opus-4-7\u0026#34;, \u0026#34;display_name\u0026#34;: \u0026#34;Opus 4.7\u0026#34; }, \u0026#34;workspace\u0026#34;: { \u0026#34;current_dir\u0026#34;: \u0026#34;/Users/nick/repo\u0026#34; }, \u0026#34;rate_limits\u0026#34;: { \u0026#34;five_hour\u0026#34;: { \u0026#34;used_percentage\u0026#34;: 51, \u0026#34;resets_at\u0026#34;: 1746920100 }, \u0026#34;seven_day\u0026#34;: { \u0026#34;used_percentage\u0026#34;: 25, \u0026#34;resets_at\u0026#34;: 1747526400 } }, \u0026#34;context_window\u0026#34;: { \u0026#34;context_window_size\u0026#34;: 1000000, \u0026#34;used_percentage\u0026#34;: 17, \u0026#34;total_input_tokens\u0026#34;: 174903 } } rate_limits.five_hour.used_percentage. rate_limits.seven_day.used_percentage. resets_at Unix timestamps. The exact two numbers and two countdowns my Stream Deck key wanted to render. Pushed to a shell script I\u0026rsquo;d already installed. Every turn. No HTTP, no token, no Cloudflare. The codelynx.dev write-up confirmed it shipped in Claude Code v1.2.80, which had been my installed version for weeks.\nRead that again. The data was being shoved at me, push-style, by a documented contract, while I was busy tuning the backoff on an undocumented endpoint that returns the same data and bans you for asking.\nThe statusline command I\u0026rsquo;d already shipped (the one I\u0026rsquo;d written a whole separate post about as a telemetry forwarder) was already getting this JSON. I was reading context_window.used_percentage out of it and ignoring the rate_limits field two keys away.\nThat was the moment the design changed.\nFix: subscribe, don\u0026rsquo;t poll # The new architecture is one extra path in the statusline forwarder and a few fields in the daemon\u0026rsquo;s state store.\nThe statusline command runs every turn and POSTs the JSON to a daemon endpoint:\n# cli/src/statusline.sh — abridged INPUT=\u0026#34;$(cat)\u0026#34; SID=\u0026#34;$(printf \u0026#39;%s\u0026#39; \u0026#34;$INPUT\u0026#34; | jq -r \u0026#39;.session_id // empty\u0026#39;)\u0026#34; if [ -n \u0026#34;$SID\u0026#34; ]; then printf \u0026#39;%s\u0026#39; \u0026#34;$INPUT\u0026#34; \\ | curl -sf -m 1 -X POST \u0026#34;http://127.0.0.1:9127/context/$SID\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ --data-binary @- \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 \u0026amp; fi The daemon receives that POST, parses rate_limits.* along with the context_window.* it was already consuming, updates stateStore.rateLimits and stateStore.lastStatuslineAt, and broadcasts. The Stream Deck plugin reads rateLimits.five_hour.used_percentage from the WebSocket state and renders. End-to-end latency is a few milliseconds. The poller doesn\u0026rsquo;t run.\nThe poller doesn\u0026rsquo;t go away, though. It gates on freshness. When lastStatuslineAt is recent, the tick short-circuits before hitting Anthropic. When no Claude session has been active for ten minutes, the poller resumes covering the display, at a much saner cadence (5-minute base, 30-minute cap) and now actually honoring Retry-After. The freshness gate is the load-bearing change; the poller\u0026rsquo;s job shrank from \u0026ldquo;primary data source\u0026rdquo; to \u0026ldquo;fallback when nobody\u0026rsquo;s running Claude\u0026rdquo;:\n// daemon/src/planUsagePoller.ts — the gate private static readonly STATUSLINE_FRESH_MS = 10 * 60_000; const lastStatusline = this.getLastStatuslineAt(); if (lastStatusline \u0026gt; 0) { const age = this.clock() - lastStatusline; if (age \u0026lt; PlanUsagePoller.STATUSLINE_FRESH_MS) { console.log(`plan poll skipped — statusline fresh (${age}ms ago)`); return; } } Net result, looking at daemon.log on a regular workday: zero polls, zero 429s. Every turn through Claude refreshes the numbers, fresher than the polled endpoint ever was, with no rate-limit exposure. The Stream Deck key is now noticeably more responsive: the 5-hour bucket ticks up during the same turn that crossed the threshold, not on the next 60-second poll.\nThe endpoint I\u0026rsquo;d been hammering for 18 hours is still in the codebase, behind the gate, polling at five minutes when there\u0026rsquo;s no Claude session, exactly the cadence the other three tools converged on. It\u0026rsquo;s the fallback path now, not the primary one.\nWhy I trust the new primitive # /api/oauth/usage got marked not planned three times. That endpoint isn\u0026rsquo;t coming back as a supported surface. The statusline JSON contract is the opposite case:\nIt\u0026rsquo;s publicly documented. It ships in stable Claude Code releases (rate_limits since v1.2.80, per the codelynx.dev write-up). Multiple ecosystem tools build on it: ccusage, claude-code-statusline, every custom statusline anyone\u0026rsquo;s written. Anthropic actively promotes the surface, including in their own examples. If Anthropic ever ships a documented \u0026ldquo;consume usage data\u0026rdquo; REST endpoint or a webhook (the right long-term answer), the migration is a different feeder writing the same stateStore.rateLimits field. The daemon and plugin don\u0026rsquo;t change. Until then, I\u0026rsquo;m pulling from the channel they want me to pull from instead of the one they keep telling people not to.\nLessons # When you hit sustained 429 from an undocumented endpoint, the answer is rarely \u0026ldquo;tune the backoff.\u0026rdquo; It\u0026rsquo;s \u0026ldquo;find the supported primitive.\u0026rdquo; I almost shipped a beautiful Retry-After parser to a surface that closes its issues as not planned. A GitHub issue closed as not planned is a load-bearing signal. That endpoint is not coming back to your use case. Read the closures before you build on top of it. Statusline is a side channel in both directions. You can write to it (telemetry forwarding, which I\u0026rsquo;d already done) and you can read from it (rate limits, context window, session id, model). Same JSON, same per-turn cadence, both directions. Push beats pull for data the user is generating. If something fires every time the user does the action, subscribe to that. Polling a derived endpoint to learn about activity that just happened is the wrong shape. The fallback poller still needs to be correct. Honor Retry-After, raise the floor, raise the cap. Just because it\u0026rsquo;s not the primary path doesn\u0026rsquo;t mean it gets to run on the old broken cadence. The wider trap here is that \u0026ldquo;I have a daemon, daemons poll\u0026rdquo; was such a strong default that I never asked whether the data was already being pushed somewhere I owned. It was. It had been the whole time. The fix wasn\u0026rsquo;t a fix. It was a deletion of the question.\nReferences # Claude Code statusline docs: https://code.claude.com/docs/en/statusline. Schema source of truth for the JSON piped to a statusLine command\u0026rsquo;s stdin (the older docs.claude.com/en/docs/claude-code/statusline URL redirects here). codelynx.dev, Claude Code usage limits via statusline, confirms rate_limits shipped in v1.2.80: https://codelynx.dev/posts/claude-code-usage-limits-statusline anthropics/claude-code #31021, /api/oauth/usage behavior, closed not planned: https://github.com/anthropics/claude-code/issues/31021 anthropics/claude-code #31637, 30+ minute bans on /api/oauth/usage, closed invalid: https://github.com/anthropics/claude-code/issues/31637 anthropics/claude-code #30930, Retry-After: 0 returned with rate-limit response, closed without fix: https://github.com/anthropics/claude-code/issues/30930 AgentDeck bridge/src/usage-api.ts, 120-second TTL \u0026ldquo;to avoid 429\u0026rdquo;, honors Retry-After: https://github.com/puritysb/AgentDeck/blob/master/bridge/src/usage-api.ts ohugonnot\u0026rsquo;s claude-code-statusline, 300-second default, \u0026ldquo;undocumented endpoint\u0026rdquo; disclaimer in README: https://github.com/ohugonnot/claude-code-statusline jens-duttke\u0026rsquo;s usage-monitor-for-claude, 5/15/30-minute presets, no sub-5-minute option: https://github.com/jens-duttke/usage-monitor-for-claude RFC 9110 §10.2.3, Retry-After header semantics (delta-seconds OR HTTP-date): https://httpwg.org/specs/rfc9110.html#field.retry-after ClaudeDeck statusline forwarder: cli/src/statusline.sh. The per-turn POST that now also carries rate_limits. ClaudeDeck poller with freshness gate: daemon/src/planUsagePoller.ts. STATUSLINE_FRESH_MS = 10 * 60_000 short-circuits the tick when statusline is fresh. ClaudeDeck SessionStart auto-patcher: daemon/src/statuslineAutoPatch.ts. Installs the forwarder into project-level settings on first session. Companion post on the same statusline primitive viewed from the write direction: The Claude Code statusline is a per-turn telemetry side channel ","date":"10 May 2026","externalUrl":null,"permalink":"/posts/plan-usage-statusline-pivot/","section":"Posts","summary":" My daemon logged 111 consecutive HTTP 429s against `https://api.anthropic.com/api/oauth/usage` over an 18-hour stretch, with zero successful responses ever in its lifetime. The poller was reading `Retry-After: 272` and ignoring it. While I was arguing with the backoff, Claude Code was pushing the same `rate_limits.five_hour` and `rate_limits.seven_day` numbers to my statusline command every turn, on stdin, for free. Building ClaudeDeck · Part 5 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Quick framing: Claude Code is Anthropic’s terminal CLI for Claude; Claude Max is the higher-tier subscription plan with weekly and 5-hour usage windows. HTTP 429 is “Too Many Requests”, the server’s polite way of saying “back off.” Retry-After is the response header that tells the client how long to wait. OAuth is the auth protocol Claude Code uses to talk to Anthropic on behalf of a logged-in user. And the statusline (the same one I covered in the statusline side-channel post) is the script Claude Code spawns every turn with a JSON blob on stdin.)\n","title":"I polled an undocumented endpoint for 18 hours. The data was on stdin.","type":"posts"},{"content":" I built a Claude Code permission gate that holds an HTTP response open until a Stream Deck key is pressed. Then I needed to inject a keystroke into Claude Code's own TTY so a key press could write `1\\r` straight into Claude's stdin. Bun can hold HTTP open all day. Bun cannot reliably wrap a child PTY through `node-pty` and capture the parent shell's PID. So I split my daemon: HTTP and WebSocket stay on Bun, and a Node CommonJS subprocess owns the PTY that runs Claude. Building ClaudeDeck · Part 7 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Quick grounding before the story: a PTY (pseudo-terminal) is the kernel object every interactive shell talks to. It\u0026rsquo;s a pair of file descriptors, master and slave; the program reads/writes the slave end as if it were a real terminal, and anything you write to the master end looks to that program like a human typing. The TTY is the slave end seen from the child\u0026rsquo;s side. node-pty is Microsoft\u0026rsquo;s library that gives a JavaScript parent process a writable handle to the master. Bun is a JavaScript runtime, Node\u0026rsquo;s faster sibling, and Node CommonJS is plain old require()-based Node, no transpile step. The story below is about which runtime owns the PTY.)\nThis post is the architectural follow-up to the permission round-trip post: that one ends with an Update footer noting the daemon has shifted from \u0026ldquo;hold the HTTP response open for 590 seconds\u0026rdquo; to \u0026ldquo;fire-and-forget hook + write the keystroke into Claude\u0026rsquo;s PTY\u0026rdquo;. This is the why.\nSymptom: the hold-open response stopped winning the race # The original ClaudeDeck permission gate worked like this. Claude\u0026rsquo;s PreToolUse hook POSTs to a local daemon. The daemon holds the HTTP response open. The user presses YES on the Stream Deck. The plugin sends permission:respond over WebSocket. The daemon writes {\u0026quot;hookSpecificOutput\u0026quot;:{\u0026quot;permissionDecision\u0026quot;:\u0026quot;allow\u0026quot;}} into the still-open HTTP response. Claude reads it, treats the tool as approved, runs it.\nThat works. Until Claude\u0026rsquo;s own y/n terminal prompt renders faster than the hook decision can travel. Then Claude is sitting there with two input channels open at once: the hook (waiting on the held-open HTTP response) and the terminal prompt (waiting on stdin). Whichever lands first wins. With a slow human in the loop and a fast TUI, the terminal prompt wins more often than the hook does. The user presses YES on the Stream Deck, the daemon happily writes its permissionDecision: \u0026quot;allow\u0026quot; into the held response, and Claude, already past that decision, interprets the incoming JSON as the next user message.\nThe reasonable fix: stop racing the terminal prompt. Write the keystroke straight into Claude\u0026rsquo;s PTY, as if a human had typed 1\\r. One input channel. No race.\nTo do that, ClaudeDeck has to own the PTY that Claude is running in. Which means claudedeck claude has to spawn claude through node-pty. Which is where Bun gets in the way.\nInvestigation: Bun\u0026rsquo;s node-pty integration doesn\u0026rsquo;t allocate a real TTY # node-pty is Microsoft\u0026rsquo;s PTY library. It exposes pty.spawn(bin, args, opts) and returns a duplex stream (a stream that\u0026rsquo;s both readable and writable: read() for what the child wrote, write() for what we want the child to see as input) you can read from and write to as if it were a terminal. Under Node.js it works. Every Electron terminal app, every VS Code integrated terminal, every Hyper, leans on it.\nUnder Bun (1.3.x at the time I tried), it doesn\u0026rsquo;t. On 2026-04-23 I ran the same five-line test in both runtimes: spawn bash, write echo hello\\r, read until prompt:\nUnder Node 22: bash starts, prints its prompt, accepts the echo keystroke, prints hello, closes cleanly. Under Bun 1.3.13: bash spawns, immediately exits with SIGHUP (the \u0026ldquo;hang-up\u0026rdquo; signal Unix sends when a terminal goes away; a shell whose controlling terminal disappears under it shuts down), no output, no prompt. This isn\u0026rsquo;t unique to me. The Bun issue tracker has a multi-year thread on node-pty integration failing under Bun (oven-sh/bun#7362): the native .node addon (a compiled C/C++ extension Node loads via require(); node-pty has one because spawning a PTY needs forkpty/openpty syscalls) doesn\u0026rsquo;t resolve _node_module_register symbols against Bun\u0026rsquo;s flat namespace. The microsoft/node-pty side has the corresponding \u0026ldquo;Bun support\u0026rdquo; tracker (microsoft/node-pty#632). Even when the addon loads, TTY allocation through Bun\u0026rsquo;s spawn path doesn\u0026rsquo;t produce the controlling-terminal relationship bash/zsh/claude expect, so the child sees its stdin as closed and exits on SIGHUP.\nI tried the obvious workarounds first.\nBun.spawn with stdio: [\u0026quot;inherit\u0026quot;, \u0026quot;inherit\u0026quot;, \u0026quot;inherit\u0026quot;]. Gets you a real TTY in the child, but only Ghostty\u0026rsquo;s TTY, the same one your shell is on. You can\u0026rsquo;t write keystrokes into it from another process. Inherit means \u0026ldquo;I am the user\u0026rsquo;s keyboard\u0026rdquo;, not \u0026ldquo;I am a programmable channel to the user\u0026rsquo;s keyboard\u0026rdquo;. Bun.spawn with a pipe for stdin. Claude\u0026rsquo;s TUI checks isatty(0) at startup (isatty is the POSIX call that asks \u0026ldquo;is file descriptor 0 (stdin) an actual terminal, or just a pipe/file?\u0026rdquo;). Pipe-backed stdin isn\u0026rsquo;t a TTY, so Claude refuses to render its interactive UI and exits. script(1) as an external PTY wrapper. (script is a BSD-era CLI that records a session by allocating a PTY and copying output to a file; people sometimes lean on it as a generic PTY allocator.) Works for I/O but obscures the process tree, so I lose the direct parent/child relationship I need to capture the shell PID (more on that below). Wait for Bun to fix node-pty. Realistic, but the issue has been open since 2023; I had a feature blocked today. So one of two paths: ditch Bun entirely, or pay the cost of a runtime split.\nInvestigation: capturing the parent shell PID has its own constraint # Independently, I needed the PID of the shell (the parent zsh / bash that the terminal emulator launched) that owns the Ghostty tab (Ghostty is my terminal emulator; the \u0026ldquo;tab\u0026rdquo; is the same thing iTerm or Terminal.app calls a tab) claudedeck claude is running in. The reason is the \u0026ldquo;jump to tab\u0026rdquo; feature: when a permission gate fires and the user clicks YES on the Stream Deck from inside Slack, ClaudeDeck activates Ghostty and focuses the correct tab. Ghostty\u0026rsquo;s AppleScript dictionary (the published list of properties and commands a Mac app exposes to AppleScript, via osascript -e 'tell application \u0026quot;Ghostty\u0026quot; to ...') exposes a pid per terminal, and that PID is the controlling shell, not the bun process, not the Claude process. (docs/2026-04-22-option-x-a-plan.md §6 has the full feasibility analysis.)\nI tried capturing that shell PID from Bun. The problem: by the time claudedeck claude is running, the process tree looks like this:\nghostty ← Ghostty itself └── zsh ← controlling shell (Ghostty AS reports this) └── bash claudedeck ← user typed `claudedeck claude` └── bun (the claudedeck CLI binary) ← what `process.pid` gives Bun └── (eventually: claude, if we spawn it here) Bun is four levels deep from the shell PID Ghostty reports. I can walk up ps -o ppid=,comm= (the BSD ps output format string: ppid is \u0026ldquo;parent process ID\u0026rdquo;, comm is \u0026ldquo;command name\u0026rdquo;; together that\u0026rsquo;s a one-line per-process row showing each parent and what binary it\u0026rsquo;s running) until I find a process whose parent is ghostty, sure. But I have to be a child of that shell to do it. If I move PTY ownership somewhere else (a separate script(1) wrapper, a launchd-managed helper), the ancestor chain rearranges and the resolver returns the wrong PID. The capture has to happen in the same process that\u0026rsquo;s about to spawn Claude. Which has to be the same process that owns the PTY. Which can\u0026rsquo;t be Bun.\nRoot cause: two constraints, one runtime, can\u0026rsquo;t satisfy both # The two requirements compose badly:\nPTY ownership needs Node (because node-pty doesn\u0026rsquo;t work under Bun). Shell PID capture needs to happen in a process whose ancestor chain still includes the controlling shell. There\u0026rsquo;s no Bun-only path that satisfies (1). There\u0026rsquo;s no \u0026ldquo;spawn an unrelated helper\u0026rdquo; path that satisfies (2). The two have to be satisfied by the same process, and that process can\u0026rsquo;t be Bun.\nThe rest of the daemon (HTTP hook endpoints, WebSocket fanout to plugins, plan-usage polling, the state store) has zero PTY needs. Bun handles all of it better than Node would (faster HTTP, native Bun.serve, native WebSocket). Throwing Bun out to fix one feature would punish four others.\nThe constraint isn\u0026rsquo;t \u0026ldquo;Bun is wrong\u0026rdquo; or \u0026ldquo;Node is wrong\u0026rdquo;. The constraint is one runtime can\u0026rsquo;t do both jobs.\nFix: a Node CJS subprocess for the PTY, Bun for everything else # What ClaudeDeck does today, in cli/src/claudedeck.ts:\nconst proc = Bun.spawn([nodeBin, runnerPath, claudeBin, ...args], { stdio: [\u0026#34;inherit\u0026#34;, \u0026#34;inherit\u0026#34;, \u0026#34;inherit\u0026#34;], }); return await proc.exited; runnerPath points at cli/src/ptyRunner.cjs, a CommonJS file (deliberately .cjs, not .ts, so Node can require it without a transpile step). It:\nrequire(\u0026quot;node-pty\u0026quot;) and pty.spawn Claude with xterm-256color (the $TERM value, which tells the child program what terminal capabilities to assume; this is the modern colour-capable default). Wires process.stdin → pty.write and pty.onData → process.stdout, with setRawMode(true) (raw mode = no kernel line buffering or Ctrl-C handling at this level; every keystroke flows through immediately, which is what an interactive TUI needs) so keystrokes flow verbatim. Resolves its ancestor PID chain via cli/src/shellPidResolver.cjs, which walks ps -o ppid=,comm= until it hits a process whose parent comm matches /ghostty/i. Opens a WebSocket to the Bun daemon at ws://127.0.0.1:9127/ws and sends pty-holder:register with {cwd, pid, ancestorPids}. On incoming pty-write events, calls proc.write(data). That\u0026rsquo;s the moment a Stream Deck press becomes 1\\r in Claude\u0026rsquo;s stdin. The Bun daemon doesn\u0026rsquo;t change shape much. Its permission:respond handler in daemon/src/server.ts gets a new branch: look up the PTY holder by cwd, send a pty-write event over the existing WebSocket. The keystroke mapping lives in responseToKeystroke(): allow → \u0026quot;1\\r\u0026quot;, kind=always → \u0026quot;2\\r\u0026quot;, deny → \u0026quot;3\\r\u0026quot;.\nThe hook endpoint becomes fire-and-forget. It returns {received: true} immediately and lets Claude render its own y/n prompt. The Stream Deck press writes the answer into the prompt directly. One input channel; no held HTTP response; no race.\nstdio: [\u0026quot;inherit\u0026quot;, \u0026quot;inherit\u0026quot;, \u0026quot;inherit\u0026quot;] on the Bun.spawn call is load-bearing. It means Ghostty\u0026rsquo;s TTY flows bun → node → ptyRunner unchanged, so when node-pty allocates a master/slave pair inside the Node subprocess, the slave end is a proper controlling terminal. Lose the inherit and Claude\u0026rsquo;s isatty(0) check fails again.\nThere\u0026rsquo;s a small bonus: because the Node subprocess is a direct child of the Bun process, which is a direct child of bash claudedeck, which is a direct child of zsh, the ancestor chain the resolver walks is the same one Ghostty\u0026rsquo;s AppleScript dictionary sees. The shell PID capture only works because the PTY runner is in the same process tree.\nThe thing that surprised me about PTY-write as IPC # Once you\u0026rsquo;ve got a writable PTY, you have the cleanest cross-process keystroke injection on macOS that I know of. No CGEventPost. No Accessibility permission grant. No CGXSenderCanSynthesizeEvents filter that Tahoe might have tightened. No osascript \u0026quot;tell application System Events to keystroke\u0026quot; Apple events. Just proc.write(\u0026quot;1\\r\u0026quot;) into a file descriptor you already own.\nThe reason: a PTY is a kernel-level construct. Writes to the master end are byte-for-byte indistinguishable from a human typing on the controlling terminal. The same kernel code path that turns USB-keyboard bytes into stdin reads also turns master-end writes into stdin reads. The target process doesn\u0026rsquo;t get to discriminate. It also doesn\u0026rsquo;t need to opt in.\nThe catch is the master-end has to belong to your process. You can\u0026rsquo;t pty-write into a TUI you didn\u0026rsquo;t spawn. For ClaudeDeck this is exactly the right shape: the user opts into this model by typing claudedeck claude instead of claude, and from that point on the daemon has a clean injection channel for as long as the wrapper is alive.\n(Could I have done this with Carbon hotkeys synthesizing events into the frontmost terminal? On Tahoe, no. The OS now filters synthesized events from ad-hoc binaries. PTY-write sidesteps the whole filter because the kernel never sees these as \u0026ldquo;synthesized\u0026rdquo; events. They\u0026rsquo;re just bytes on a file descriptor.)\nLessons # Runtime choice is a constraint, not a preference. When a feature ceiling is the runtime itself, split before you rewrite. Bun\u0026rsquo;s HTTP and WebSocket throughput are worth keeping; one Node subprocess for one capability is cheaper than porting four working things to a new runtime. PTY-write is the cleanest cross-process keystroke injection on macOS, when you own the PTY. No Accessibility grant, no event-synthesis filter, no osascript. Just bytes on a file descriptor. Process tree placement is a feature. Capturing a parent shell PID requires being a child of that shell. Move the capture into a sibling or launchd-managed helper and the ancestor chain rearranges out from under you. stdio: \u0026quot;inherit\u0026quot; is the seam between runtimes. It\u0026rsquo;s how Ghostty\u0026rsquo;s TTY flows bun → node → ptyRunner without losing controlling-terminal-ness. Pipes break this; inherit preserves it. A .cjs file in a TypeScript project isn\u0026rsquo;t a code smell. It\u0026rsquo;s a transpile boundary. Node can require it without a build step, so the bun → node hand-off is one Bun.spawn and zero shared compilation. Two simultaneous input channels into a TUI is one too many. If a feature races a process\u0026rsquo;s existing prompt, the feature loses on a slow human. Replace the race, don\u0026rsquo;t tune it. References # /posts/permission-round-trip/: the original hold-open HTTP design this migration replaced. /posts/tahoe-hotkey-dead-end/: the parallel macOS keystroke-injection dead end; PTY-write sidesteps the same filter. ClaudeDeck phased migration plan: docs/2026-04-22-option-x-a-plan.md (Option X-A; phase 6 documents the shellPid-capture decision). PTY runner (Node subprocess): cli/src/ptyRunner.cjs. Shell PID resolver (walks ps -o ppid=,comm= until parent matches Ghostty): cli/src/shellPidResolver.cjs. PTY ring buffer (output capture for debugging): daemon/src/ptyRingBuffer.ts. Bun-side spawn of the Node subprocess + stdio: \u0026quot;inherit\u0026quot; rationale: cli/src/claudedeck.ts (runClaude). Daemon-side pty-write routing + keystroke mapping: daemon/src/server.ts (routePtyWrite, responseToKeystroke). Bun + node-pty compatibility issue: oven-sh/bun#7362, \u0026ldquo;node-pty unable to be run from bun\u0026rdquo;. Corresponding tracker on the node-pty side: microsoft/node-pty#632, \u0026ldquo;Bun support\u0026rdquo;. node-pty library: https://github.com/microsoft/node-pty. ","date":"10 May 2026","externalUrl":null,"permalink":"/posts/pty-wrap-migration/","section":"Posts","summary":" I built a Claude Code permission gate that holds an HTTP response open until a Stream Deck key is pressed. Then I needed to inject a keystroke into Claude Code's own TTY so a key press could write `1\\r` straight into Claude's stdin. Bun can hold HTTP open all day. Bun cannot reliably wrap a child PTY through `node-pty` and capture the parent shell's PID. So I split my daemon: HTTP and WebSocket stay on Bun, and a Node CommonJS subprocess owns the PTY that runs Claude. Building ClaudeDeck · Part 7 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Quick grounding before the story: a PTY (pseudo-terminal) is the kernel object every interactive shell talks to. It’s a pair of file descriptors, master and slave; the program reads/writes the slave end as if it were a real terminal, and anything you write to the master end looks to that program like a human typing. The TTY is the slave end seen from the child’s side. node-pty is Microsoft’s library that gives a JavaScript parent process a writable handle to the master. Bun is a JavaScript runtime, Node’s faster sibling, and Node CommonJS is plain old require()-based Node, no transpile step. The story below is about which runtime owns the PTY.)\n","title":"I split my daemon in two so a Node subprocess could own the PTY","type":"posts"},{"content":" `launchctl unload ~/Library/LaunchAgents/com.nickboy.claudedeck.plist` exited 0. Then `pgrep -f claudedeck-daemon` printed a fresh PID. Three seconds after the \"unload succeeded\" line. Spoiler: KeepAlive is a polling supervisor, not an event-driven one, and when you tell launchd to tear a job down, there is a window where the supervisor has already noticed the previous PID is gone and started a replacement. Building ClaudeDeck · Part 10 of 10 1 2 3 4 5 6 7 8 9 10 (One-paragraph grounding if launchd isn\u0026rsquo;t your daily driver: launchd is macOS\u0026rsquo;s init system, the equivalent of systemd on Linux or Windows Services on Windows. It boots PID 1, brings up daemons, restarts them when they crash. A LaunchAgent is a per-user launchd job, defined by an XML plist (property list) at ~/Library/LaunchAgents/\u0026lt;name\u0026gt;.plist. KeepAlive is one of the plist keys; set it to true and launchd will respawn the job whenever it exits. launchctl is the CLI you use to load, unload, and inspect those jobs. The Linux mental model: think systemctl driving systemd unit files. The Stream Deck plugin and its daemon are described in the TCC cdhash trap post if you want the project context.)\nI\u0026rsquo;m building a Stream Deck plugin called ClaudeDeck. Its daemon runs under a user LaunchAgent so it comes up at login and stays up: RunAtLoad: true, KeepAlive: true, the obvious shape. Writing the plist took ten minutes. Getting the daemon to go away when I wanted it to took a weekend.\nThe story below is one specific bug: the daemon refusing to die. I\u0026rsquo;ll skip the bootstrap-domain confusion I hit earlier (the gui/$UID vs system thing. launchd has multiple \u0026ldquo;domains\u0026rdquo; you can load a job into, one per logged-in GUI user and one system-wide, and pointing launchctl at the wrong one is the most common first-time confusion; well-covered elsewhere) and stick to the part that surprised me.\nThe symptom # I had a disable.sh script for pausing the daemon when I wanted to debug unrelated AppleEvent issues. The first version was three lines:\nlaunchctl unload \u0026#34;$PLIST\u0026#34; mv \u0026#34;$PLIST\u0026#34; \u0026#34;$PLIST.disabled\u0026#34; echo \u0026#34;claudedeck: disabled.\u0026#34; Worked on paper. Failed in practice. The verification step I added later, a sanity pgrep, caught it:\nclaudedeck: launchctl unload... claudedeck: plist parked at ...com.nickboy.claudedeck.plist.disabled claudedeck: verification: ✅ launchctl no longer lists com.nickboy.claudedeck ⚠️ claudedeck-daemon still running — try: pkill -9 -f claudedeck-daemon Two things were true at once:\nlaunchctl list | grep claudedeck was empty. The job was unloaded. pgrep -f claudedeck-daemon printed a PID. The process was alive. If you\u0026rsquo;ve never seen this combination, it looks like a lie. launchd is the only thing that should have a handle on this daemon, and launchd just told me it doesn\u0026rsquo;t.\nWhat I thought was happening # My mental model was: launchctl unload is synchronous. By the time the command returns, the job is torn down: supervisor gone, child SIGTERM\u0026rsquo;d (sent the \u0026ldquo;please shut down gracefully\u0026rdquo; signal, number 15, the default for kill), exit reaped (the parent called wait() on the child\u0026rsquo;s exit status so the kernel can release its PID slot). That\u0026rsquo;s how systemctl stop behaves on Linux. That\u0026rsquo;s how I assumed launchd behaves too.\nWrong, but in a subtle way. launchctl unload is synchronous about the unload itself. What it\u0026rsquo;s not synchronous about is whatever the supervisor did in the milliseconds before you called it.\nWhat I tried first # Standard debugging moves. None of them were the answer, but the dead ends are part of the shape of the bug.\nTry one: maybe KeepAlive: true is too aggressive. I swapped it for KeepAlive: { SuccessfulExit: false }, the variant that only restarts on crash. No change. The straggler PID still appeared.\nTry two: maybe launchctl unload is the legacy verb and bootout is the synchronous one. Apple deprecated load/unload years ago in favour of bootstrap/bootout (the newer verbs take an explicit domain: gui/$(id -u) for \u0026ldquo;this logged-in user\u0026rsquo;s GUI session,\u0026rdquo; system for the LaunchDaemon scope). I rewrote the script:\nlaunchctl bootout \u0026#34;gui/$(id -u)/com.nickboy.claudedeck\u0026#34; Same result. The job left launchctl list. The process didn\u0026rsquo;t leave pgrep.\nTry three: Console.app. (Console.app is macOS\u0026rsquo;s built-in system-log viewer, the GUI front-end for log show. You filter by subsystem in the search bar, and com.apple.xpc.launchd is launchd\u0026rsquo;s own subsystem identifier; XPC is the macOS inter-process-comms layer launchd is built on top of.) This was the click. I filtered for com.apple.xpc.launchd and ran disable again. The relevant lines:\nlaunchd: Service exited: com.nickboy.claudedeck — Killed: 15 launchd: Service instance exited cleanly launchd: Service spawned with PID 67421 launchd: Service exited: com.nickboy.claudedeck — Killed: 15 Read that twice. The job got SIGTERM. It exited. Then launchd spawned a new instance (with a new PID, 67421) and then sent that one SIGTERM too. Two restart cycles inside a single bootout.\nRoot cause # KeepAlive is a polling supervisor, not an event-driven one. (This is the most useful sentence in this post; the rest of it is just unpacking that sentence.) Concretely:\nlaunchd keeps a per-job \u0026ldquo;should this be running?\u0026rdquo; predicate. With KeepAlive: true it\u0026rsquo;s always yes. A separate path watches for SIGCHLD on the supervised process. (SIGCHLD is the Unix signal a process receives when one of its children exits, the kernel\u0026rsquo;s way of saying \u0026ldquo;you have a corpse to reap.\u0026rdquo; launchd\u0026rsquo;s supervisor handler responds by checking the predicate.) When a child exits, launchd evaluates the predicate. If the answer is \u0026ldquo;yes\u0026rdquo;, it spawns a replacement. bootout flips the predicate to no, then sends SIGTERM, then reaps. The race lives in the gap between the child exiting and bootout flipping the predicate. If SIGCHLD lands first (and on a multi-core machine under light load, it usually does), the supervisor sees \u0026ldquo;child died, predicate still true, restart\u0026rdquo; before bootout gets to flip the predicate. So you get a fresh PID milliseconds before the unload finishes. From the outside, launchctl list is empty (the job\u0026rsquo;s gone) but a daemon PID is still in the process table (because nothing told the replacement to die in a way the orphan reaper, the system process that adopts and eventually wait()s on parentless children, would notice quickly).\nIn my case I was making it worse. The daemon I was disabling had a graceful-shutdown handler:\nprocess.on(\u0026#34;SIGTERM\u0026#34;, () =\u0026gt; void shutdown(\u0026#34;SIGTERM\u0026#34;)); shutdown() flushes session state, closes WebSocket connections, awaits in-flight HTTP responses. That takes a few hundred milliseconds. During those few hundred milliseconds, KeepAlive\u0026rsquo;s poller is happily watching the original PID, which is still alive but draining. The moment that PID exits, the poller fires a respawn, which races bootout\u0026rsquo;s predicate flip and sometimes wins.\nThe cleaner the shutdown, the wider the race window.\nThe fix # The fix isn\u0026rsquo;t on the launchd side. There is no bootout --really-synchronous flag. There\u0026rsquo;s no KeepAlive: { NoRaces: true }. The fix is to accept that launchctl has told you the truth about the job and then independently verify the process is gone:\n# 3. Stop the running daemon. `launchctl unload` is idempotent enough # that we ignore its exit code — if it was never loaded we still # want to proceed to renaming the plist. launchctl unload \u0026#34;$PLIST\u0026#34; 2\u0026gt;/dev/null || true # 4. Belt-and-suspenders: kill any straggler the daemon left behind # (e.g. KeepAlive raced our unload, or `bun build --compile` is # still running an older copy from a different path). if pgrep -f claudedeck-daemon \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then pkill -9 -f claudedeck-daemon || true echo \u0026#34;claudedeck: killed leftover daemon process\u0026#34; fi That\u0026rsquo;s scripts/disable.sh lines 47-59. SIGKILL (-9, the signal the kernel doesn\u0026rsquo;t let a process catch or ignore: instant termination, no shutdown handler) is intentional, not lazy: the straggler is by definition the second instance, the one that didn\u0026rsquo;t get the graceful-shutdown SIGTERM. Sending it SIGTERM would just restart the cycle. The job is already unloaded, so SIGKILL on an orphaned child doesn\u0026rsquo;t trigger another respawn.\nI also renamed the plist to \u0026lt;name\u0026gt;.plist.disabled after the kill, because if I left the plist in place and the kill triggered a SIGCHLD before launchd had fully removed the job from its tables, I\u0026rsquo;d be back to square one. Rename means \u0026ldquo;even if a respawn slips through, it\u0026rsquo;ll fail to find a plist on the next iteration.\u0026rdquo; Belt and suspenders and a third belt.\nWhy I didn\u0026rsquo;t see this immediately # Two things hid the bug for a while.\nFirst, the install path uses launchctl bootstrap gui/$UID \u0026lt;plist\u0026gt;: see cli/src/install.ts line 366. The error message on failure is:\nlaunchctl bootstrap exited with 5; continuing Errno 5 (EIO, \u0026ldquo;I/O error,\u0026rdquo; the POSIX errno that launchd reuses as its catch-all for \u0026ldquo;I refuse\u0026rdquo;) from bootstrap usually means \u0026ldquo;service already loaded with this label.\u0026rdquo; I hit that constantly during development because the daemon was still loaded from the previous install. I assumed it was a stale bootstrap and re-ran. That worked. So the install side never made me look hard at the unload side.\nSecond, the doctor check (cli/src/doctor.ts line 120) only queries launchctl list and not pgrep for the process. So during a respawn window, the doctor reported red (no entry in launchctl list), the install retry succeeded (because the entry showed up), and the orphan PID sat there hogging port 9127. Which presented as \u0026ldquo;the daemon\u0026rsquo;s running but not responding to my new install\u0026rsquo;s HTTP calls\u0026rdquo;. I blamed the port, twice, before I blamed the supervisor.\nLessons # KeepAlive is a polling supervisor, not an event-driven one. Restart races are inherent. Any teardown script that doesn\u0026rsquo;t independently verify the process is gone is one SIGCHLD-timing accident away from leaking a daemon. launchctl list and pgrep are not redundant. The job and the process are different objects. A health check that asks only the supervisor whether a daemon exists will miss orphaned children. Console.app filtered to com.apple.xpc.launchd is the actual debugger. man launchctl documents the verbs, not the lifecycle. The lifecycle shows up in the log: the SIGCHLD → respawn → SIGTERM sequence is what tells you the race exists. Graceful shutdown widens the race window. Anything you do in your SIGTERM handler is time during which KeepAlive\u0026rsquo;s poller can decide your child has misbehaved and queue a replacement. Make the handler fast, or accept that your teardown script needs a SIGKILL backstop. References # launchctl(1) and launchd.plist(5) man pages, covering verb syntax and KeepAlive sub-keys. The man pages are accurate but say nothing about lifecycle ordering. Apple\u0026rsquo;s launchd man pages and Console.app (filtered to com.apple.xpc.launchd) are where the actual cause shows up. ClaudeDeck install path: cli/src/install.ts (the launchctl bootstrap call, with the silent \u0026ldquo;continuing\u0026rdquo; on non-zero exit) ClaudeDeck plist generator: hooks/launchd.ts (RunAtLoad: true, KeepAlive: true) ClaudeDeck doctor\u0026rsquo;s launchd check: cli/src/doctor.ts (the launchctl list | grep \u0026lt;label\u0026gt; probe that misses orphan processes) ClaudeDeck disable script: scripts/disable.sh (the pkill -9 belt-and-suspenders) Related post on this site: TCC pins your Accessibility grant to a cdhash. Every rebuild breaks it., same daemon, different macOS-internals trap. ","date":"10 May 2026","externalUrl":null,"permalink":"/posts/launchd-bootstrap-debugging/","section":"Posts","summary":" `launchctl unload ~/Library/LaunchAgents/com.nickboy.claudedeck.plist` exited 0. Then `pgrep -f claudedeck-daemon` printed a fresh PID. Three seconds after the \"unload succeeded\" line. Spoiler: KeepAlive is a polling supervisor, not an event-driven one, and when you tell launchd to tear a job down, there is a window where the supervisor has already noticed the previous PID is gone and started a replacement. Building ClaudeDeck · Part 10 of 10 1 2 3 4 5 6 7 8 9 10 (One-paragraph grounding if launchd isn’t your daily driver: launchd is macOS’s init system, the equivalent of systemd on Linux or Windows Services on Windows. It boots PID 1, brings up daemons, restarts them when they crash. A LaunchAgent is a per-user launchd job, defined by an XML plist (property list) at ~/Library/LaunchAgents/\u003cname\u003e.plist. KeepAlive is one of the plist keys; set it to true and launchd will respawn the job whenever it exits. launchctl is the CLI you use to load, unload, and inspect those jobs. The Linux mental model: think systemctl driving systemd unit files. The Stream Deck plugin and its daemon are described in the TCC cdhash trap post if you want the project context.)\n","title":"launchctl unload returned 0. The daemon was still running. KeepAlive raced.","type":"posts"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/launchd/","section":"Tags","summary":"","title":"Launchd","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/macos/","section":"Tags","summary":"","title":"Macos","type":"tags"},{"content":" I rebuilt my portfolio site in a weekend using vibe coding with Claude Code . This is the real workflow, no hype, with honest tradeoffs. Andrej Karpathy coined \u0026ldquo;vibe coding\u0026rdquo; in early 2025. The pitch: describe what you want in natural language, let the AI write the code, spend your time directing instead of typing. Collins Dictionary named it Word of the Year. Most takes on it are either breathless hype or dismissive eye-rolls. Here\u0026rsquo;s what it looks like when a senior engineer uses it for a real project.\nWhy I Rebuilt This Site # My old portfolio was stale. It was a basic Jekyll site I\u0026rsquo;d set up years ago and barely touched. I wanted something that:\nLooked modern without me fighting CSS for a week Had a blog for technical writing Was fast and easy to deploy Didn\u0026rsquo;t require a JavaScript framework for what\u0026rsquo;s essentially static content I chose Hugo because I\u0026rsquo;d used it before and respected its speed. The Blowfish theme had the profile-style homepage I wanted, dark mode out of the box, and Tailwind built in. I didn\u0026rsquo;t evaluate 50 themes. I saw one that matched my mental picture and committed.\nThat kind of decision (knowing what you want and picking quickly) is something AI can\u0026rsquo;t do for you.\nMy Setup # The core of my vibe coding setup is straightforward:\nClaude Code running in my terminal (Ghostty + tmux) A CLAUDE.md file at the project root defining architecture, commands, and conventions Plan Mode for anything non-trivial (more on this below) Git on a feature branch so I can always roll back The CLAUDE.md file is the most underrated part of this workflow. Think of it as the onboarding doc for your AI pair programmer. Mine specifies Hugo version, theme, config locations, deployment target, conventions. Without it, every conversation starts from zero. What I Tried Before # Before settling on Claude Code, I\u0026rsquo;d spent time with Copilot for autocomplete and Cursor for structured edits. Both are good tools. But for this kind of project (scaffolding an entire site from scratch, wiring up a theme, writing CI) I needed something that could reason about the project as a whole rather than the file I had open. The full breakdown of why I landed on Claude Code is in my tool comparison.\nThe Real Workflow, Step by Step # A typical session looks like this:\nDescribe the Goal Step 1 I start by describing what I want in plain English: the outcome, not the implementation. Example: \"Migrate the site from the default Blowfish config to a profile-style homepage with my work experience and projects.\" Plan Mode Step 2 For anything touching multiple files, I use Plan Mode. Claude reads the codebase, proposes a plan, and I review it before any code gets written. This is where I catch bad architectural decisions early. Generate and Review Step 3 Claude writes the code. I review every change. Not line-by-line, but structurally. Does this match how Blowfish expects configs? Is this the right Hugo partial to override? Iterate and Fix Step 4 The first pass almost never works perfectly. I run hugo server, see what's off, describe the issue. Sometimes I just fix it myself, especially design choices and CSS tweaks. Commit and Move On Step 5 Feature works, I commit on the branch, move on. Small commits. Easy reverts. Where AI Saved Me Hours # Some tasks are perfect for vibe coding:\nCI/CD pipeline: I described my deployment target (Cloudflare Pages) and got a working GitHub Actions workflow in one shot. Setting up Hugo builds with the right version pinning would have eaten an hour of docs-reading. Boilerplate content structure: The data files for experience, projects, and education, produced with the exact YAML schema Blowfish expects. Theme configuration: Blowfish has dozens of params. Claude read the theme docs and generated a coherent params.toml matching the settings I described. Shortcode usage: Instead of reading Blowfish\u0026rsquo;s shortcode docs for every component, I described what I wanted (\u0026ldquo;a timeline showing my workflow steps\u0026rdquo;) and got working markup back. Where I Had to Take Over # Other tasks fall apart:\nDesign taste: \u0026ldquo;Make it look good\u0026rdquo; isn\u0026rsquo;t a useful prompt. I had to make specific choices about color schemes, spacing, what to highlight on the homepage. Architectural decisions: Hugo\u0026rsquo;s data templates or content pages for work experience? That\u0026rsquo;s a structural choice with cascading consequences. I decided, then told the AI. Debugging theme quirks: When Blowfish\u0026rsquo;s Hugo compatibility warning showed up, I had to read it and decide to ignore it. When the Jekyll .nojekyll file kept reappearing, I had to dig in myself. Content and voice: The actual writing (blog posts, about page, project descriptions) is all me. AI can scaffold structure. It can\u0026rsquo;t know what I want to emphasize about my career. My Prompt Patterns # A few patterns emerge after dozens of sessions.\nBe specific about the outcome, vague about implementation:\n\u0026ldquo;Add a blog section that shows the 5 most recent posts on the homepage with title, date, and a 2-line summary. Use whatever Blowfish shortcodes or partials are appropriate.\u0026rdquo;\nReference existing patterns:\n\u0026ldquo;Create a new blog post following the same front matter format as my existing posts. The topic is terminal setup.\u0026rdquo;\nProvide constraints:\n\u0026ldquo;Update the CI pipeline but keep the build under 2 minutes. Don\u0026rsquo;t add any npm dependencies.\u0026rdquo;\nToo vague:\n\u0026ldquo;Make the site better.\u0026rdquo;\nThis generates random changes with no clear direction.\nToo prescriptive:\n\u0026ldquo;On line 47 of params.toml, change the value of showRecent to 5 and then on line 52\u0026hellip;\u0026rdquo;\nIf you\u0026rsquo;re specifying exact line numbers, just edit the file yourself.\nNo context:\n\u0026ldquo;Fix the bug.\u0026rdquo;\nWhich bug? What\u0026rsquo;s the expected behavior? What did you observe? AI needs the same context a human colleague would.\nThe Honest Tradeoffs # What I Gained # Speed on boilerplate: Tasks that are well-documented but tedious (config files, CI pipelines, data schemas) drop from hours to minutes. Lower context-switching cost: I describe what I need and iterate, instead of bouncing between docs, Stack Overflow, and my editor. Exploration: I tried Blowfish features I\u0026rsquo;d never have bothered with manually (charts, timelines, keyword lists), because the cost of experimenting was near zero. What Felt Off # False confidence: Sometimes the generated code looks correct but has subtle issues: a Hugo partial that works in dev but breaks in production, or a config value that\u0026rsquo;s deprecated. Dependency on review skills: If I didn\u0026rsquo;t already know Hugo, I wouldn\u0026rsquo;t catch the mistakes. Vibe coding amplifies existing knowledge. It doesn\u0026rsquo;t substitute for it. Context limits: Long sessions lose context. I learned to keep them tight, one feature at a time. Vibe coding doesn\u0026rsquo;t reduce the skill required. It moves where the skill is applied. Less typing, more reviewing: architectural calls, tradeoff judgment, catching subtle wrongness. Arguably harder than writing the code yourself. Advice for Engineers Considering This Workflow # Start with a CLAUDE.md file. Define your project\u0026rsquo;s architecture, conventions, and tools before you start prompting. Single highest-leverage thing you can do.\nUse Plan Mode for anything non-trivial. Don\u0026rsquo;t let the AI start writing code until you\u0026rsquo;ve agreed on the approach. This prevents the most expensive mistakes.\nStay on a feature branch. Vibe coding generates a lot of changes fast. You need the safety net of easy reverts.\nReview structurally, not syntactically. Don\u0026rsquo;t read every line. Ask: does this change make sense architecturally? Does it follow the patterns already in my project?\nKnow when to take over. Three rounds on the same issue and it\u0026rsquo;s faster to fix it yourself. The AI is a tool, not a pair programmer who will eventually \u0026ldquo;get it.\u0026rdquo;\nKeep sessions focused. One feature per session. Commit, then start fresh. Your AI assistant doesn\u0026rsquo;t get tired, but it does lose context.\nFor a deeper look at where vibe coding fits in professional engineering, see my post on why it amplifies senior engineers rather than replacing them.\nThis site is the proof of concept. Every page, every blog post, every CI pipeline came out of this workflow. It\u0026rsquo;s a genuinely useful tool when you know how to wield it.\n","date":"10 May 2026","externalUrl":null,"permalink":"/posts/vibe-coding-workflow/","section":"Posts","summary":" I rebuilt my portfolio site in a weekend using vibe coding with Claude Code . This is the real workflow, no hype, with honest tradeoffs. Andrej Karpathy coined “vibe coding” in early 2025. The pitch: describe what you want in natural language, let the AI write the code, spend your time directing instead of typing. Collins Dictionary named it Word of the Year. Most takes on it are either breathless hype or dismissive eye-rolls. Here’s what it looks like when a senior engineer uses it for a real project.\n","title":"My Vibe Coding Workflow: How I Built This Site with Claude Code","type":"posts"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/node/","section":"Tags","summary":"","title":"Node","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/permissions/","section":"Tags","summary":"","title":"Permissions","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/plugin/","section":"Tags","summary":"","title":"Plugin","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/pty/","section":"Tags","summary":"","title":"Pty","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/rate-limiting/","section":"Tags","summary":"","title":"Rate-Limiting","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/sdk/","section":"Tags","summary":"","title":"Sdk","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/shell-integration/","section":"Tags","summary":"","title":"Shell-Integration","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/statusline/","section":"Tags","summary":"","title":"Statusline","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/streamdeck/","section":"Tags","summary":"","title":"Streamdeck","type":"tags"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/tcc/","section":"Tags","summary":"","title":"Tcc","type":"tags"},{"content":" My daemon's preflight log said `osascript is not allowed assistive access. (-1719)`. System Settings disagreed: the entry was right there, toggled on. Spoiler: ad-hoc codesigning pins TCC's designated requirement to the binary's cdhash, and `bun build --compile` produces a different cdhash on every rebuild. Building ClaudeDeck · Part 8 of 10 1 2 3 4 5 6 7 8 9 10 I\u0026rsquo;m building a Stream Deck plugin called ClaudeDeck (Stream Deck is Elgato\u0026rsquo;s little USB grid of programmable keys with LCD displays under each one). The plugin talks to a background daemon (a long-running process that starts at login and waits for events), and that daemon needs to call System Events via AppleScript to switch Ghostty tabs (Ghostty is my terminal emulator) whenever I press a Stream Deck key. macOS gates that capability, automating other apps, through System Settings → Privacy \u0026amp; Security → Accessibility, the pane you\u0026rsquo;ve probably toggled for tools like Rectangle or BetterTouchTool. On first install I added the daemon, toggled it on, and got back to work.\nIt worked. Once.\nThe next time I ran ./install.sh, the grant was effectively gone. The entry was still in the Accessibility list, still toggled on, but the daemon hit errAEEventNotPermitted the first time it tried to send an event. Toggle off and on? No change. Remove and re-add? That works. For exactly one more install.\nThe symptom # The daemon\u0026rsquo;s startup preflight fires a no-op AppleScript event and logs the result. After a rebuild:\naccessibility self-test: DENIED — System Events got an error: osascript is not allowed assistive access. (-1719) Meanwhile, the Accessibility pane showed the daemon present and enabled. That contradiction is what sent me down the rabbit hole.\nWhat I thought TCC was # My mental model, accidentally inherited from years of consumer-app installs, was that the Accessibility grant is keyed by app path, or maybe bundle identifier. Like a checkbox: \u0026ldquo;is ~/.local/bin/ClaudeDeckDaemon.app allowed? yes/no\u0026rdquo;. The path doesn\u0026rsquo;t change between rebuilds. The bundle ID is hardcoded. So the grant should stick.\nWrong. TCC (the Transparency, Consent, and Control subsystem, the permission database behind every macOS \u0026ldquo;allow this app to access your microphone/camera/contacts\u0026rdquo; dialog) doesn\u0026rsquo;t store \u0026ldquo;is this app allowed\u0026rdquo;. It stores \u0026ldquo;is this code allowed\u0026rdquo;, where \u0026ldquo;this code\u0026rdquo; is identified by something called a designated requirement (DR).\nWhat TCC actually stores # When you grant Accessibility, TCC writes a row in ~/Library/Application Support/com.apple.TCC/TCC.db (a SQLite database; yes, you can open it in sqlite3 to look around, though System Integrity Protection blocks writes) roughly like this:\nservice = kTCCServiceAccessibility client = com.nickboy.claudedeck.daemon -- bundle identifier client_type = 0 -- 0 = bundle ID, 1 = absolute path allowed = 1 csreq = \u0026lt;BLOB\u0026gt; -- the actual gate The csreq blob (short for \u0026ldquo;code signing requirement\u0026rdquo;, stored as a binary blob inside the SQLite row) is a serialized code requirement: Apple\u0026rsquo;s little DSL for expressing \u0026ldquo;this binary has to be signed by this party, with this identifier, anchored to this certificate.\u0026rdquo; You can dump one with codesign -d -r- (the -d is \u0026ldquo;display info\u0026rdquo;; -r- means \u0026ldquo;print the requirements to stdout\u0026rdquo;):\ndesignated =\u0026gt; identifier \u0026#34;com.nickboy.claudedeck.daemon\u0026#34; and anchor apple generic and certificate leaf = H\u0026#34;a1b2c3...\u0026#34; When the AppleScript event fires, TCC doesn\u0026rsquo;t just look up \u0026ldquo;is this bundle ID allowed\u0026rdquo;. It does a stricter check: it computes the current code identity of the calling process and asks \u0026ldquo;does it satisfy the csreq we stored at grant time?\u0026rdquo;\nIf yes: allow. If no: silent deny, return -1719.\nSo the grant doesn\u0026rsquo;t survive arbitrary code changes. It survives only changes the stored csreq will still accept. That\u0026rsquo;s the whole game.\nHow ad-hoc codesigning fits in # My install script signs the daemon with codesign --sign -. The - means ad-hoc: no identity, no certificate, just a self-contained hash of the binary\u0026rsquo;s contents. This is what every \u0026ldquo;I\u0026rsquo;m not paying $99/year for an Apple Developer account\u0026rdquo; project does to make macOS happy enough to let the binary run at all. Dump the DR of an ad-hoc binary and you get:\ndesignated =\u0026gt; cdhash H\u0026#34;d4e5f6...\u0026#34; That\u0026rsquo;s the trap. The DR is pinned to the binary\u0026rsquo;s cdhash (Apple\u0026rsquo;s term for \u0026ldquo;code directory hash\u0026rdquo;, a SHA-256 derived from the code segments and signature blob, used as the identity for the signed bundle). Change the binary by a single byte, and the cdhash changes. Stored csreq no longer matches.\nbun build --compile (the Bun JavaScript runtime\u0026rsquo;s \u0026ldquo;bundle everything into one self-contained executable\u0026rdquo; command) is not deterministic across rebuilds (oven-sh/bun#29120). Even with identical source, the embedded JS bundle has slightly different layout (timestamp metadata, content ordering, whatever Bun\u0026rsquo;s compiler decides on a given run), and the final Mach-O (macOS\u0026rsquo;s executable file format, like ELF on Linux or PE on Windows) ends up with a different cdhash.\nNew cdhash → new DR → stored csreq rejects the new code → silent deny on every AppleScript call → -1719.\nThe TCC.db row is still there. The Accessibility list still shows the app. But the gate is closed.\nWhy remove-and-re-add works (for one install) # Delete the entry in System Settings and add it back, and TCC computes a fresh csreq from the current binary\u0026rsquo;s signature and stores that. The new DR is pinned to the new cdhash. Next AppleScript call passes. Until the next ./install.sh recompiles the daemon, which is, in my case, several times an hour.\nThis is why my install loop felt like Sisyphus. Every iteration: re-grant.\nA stable bundle identifier doesn\u0026rsquo;t help # I tried the obvious workaround first. Wrap the daemon in a proper .app bundle (the directory tree macOS treats as a single application, with Contents/MacOS/ for the binary and Contents/Info.plist for the metadata) with a stable CFBundleIdentifier (the reverse-DNS string like com.apple.Safari that uniquely names the bundle):\n~/.local/bin/ClaudeDeckDaemon.app/ └── Contents/ ├── Info.plist # CFBundleIdentifier = com.nickboy.claudedeck.daemon └── MacOS/ └── claudedeck-daemon That does fix one half of the problem. launchd (macOS\u0026rsquo;s equivalent of systemd, the supervisor that brings up daemons at boot/login) now targets a stable path, and macOS knows the bundle by its identifier. But it does not fix TCC, because the stored DR for an ad-hoc-signed binary is still keyed on cdhash. The bundle ID never enters the DR for ad-hoc binaries: Apple\u0026rsquo;s code-requirement DSL only includes identifier clauses when there\u0026rsquo;s a signing identity to bind them to.\nVerified by inspection:\n$ codesign -d -r- ~/.local/bin/ClaudeDeckDaemon.app Executable=.../Contents/MacOS/claudedeck-daemon designated =\u0026gt; cdhash H\u0026#34;d4e5f6...\u0026#34; Still cdhash-pinned. Still resets on every rebuild.\nThe fix: a self-signed Keychain cert # The way out is to sign with an identity, even a self-signed one. (Keychain Access is the GUI app that manages macOS\u0026rsquo;s certificate and password stores, at /Applications/Utilities/Keychain Access.app.) Sign with a cert, and the DR shifts to:\ndesignated =\u0026gt; identifier \u0026#34;com.nickboy.claudedeck.daemon\u0026#34; and certificate leaf = H\u0026#34;\u0026lt;sha1-of-cert\u0026gt;\u0026#34; Now the DR is pinned to the cert\u0026rsquo;s leaf hash, not the binary\u0026rsquo;s cdhash. The cert\u0026rsquo;s leaf hash is stable across every binary signed with it. Sign with the same cert on every rebuild, and the DR stays the same, and TCC\u0026rsquo;s stored csreq accepts the rebuild without complaint.\nThis is the same trick yabai, skhd, and AeroSpace users (popular macOS window-management and hotkey daemons) have been doing for years. I just didn\u0026rsquo;t connect the dots until I dumped the DR and stared at it.\nCreating the cert # Three minutes in Keychain Access:\nOpen Keychain Access. Keychain Access → Certificate Assistant → Create a Certificate… Name: ClaudeDeck Code Signing Identity Type: Self Signed Root Certificate Type: Code Signing (this is the critical dropdown) Override Defaults: leave unchecked. The cert lives in your login keychain. No CA, no Apple Developer ID, no $99/year. It\u0026rsquo;s only trusted on your machine, which is fine. TCC doesn\u0026rsquo;t care whether the cert is publicly trusted; it cares whether the DR has a stable anchor. The cert provides one.\nUsing the cert # codesign picks the cert by name:\ncodesign --sign \u0026#34;ClaudeDeck Code Signing\u0026#34; --force --deep \\ --identifier com.nickboy.claudedeck.daemon \\ ~/.local/bin/ClaudeDeckDaemon.app Verify:\n$ codesign -d -r- ~/.local/bin/ClaudeDeckDaemon.app designated =\u0026gt; identifier \u0026#34;com.nickboy.claudedeck.daemon\u0026#34; and certificate leaf = H\u0026#34;a8b9c0...\u0026#34; Cdhash gone. Cert hash stays. Grant survives.\nThe probe-for-cert gotcha # One more booby trap. To detect whether the user has the cert installed, my first instinct was:\nsecurity find-identity -v -p codesigning The -v filters to valid identities. But a self-signed root reports CSSMERR_TP_NOT_TRUSTED: it\u0026rsquo;s not chained to a system trust root. Fine for codesigning purposes (the cert can still sign; codesign only checks trust when verifying someone else\u0026rsquo;s signature), but -v hides it. My probe missed the cert the user had just created. Embarrassing.\nThe fix is to drop -v:\nsecurity find-identity -p codesigning | grep \u0026#34;ClaudeDeck Code Signing\u0026#34; This catches the untrusted-but-usable self-signed cert. codesign itself doesn\u0026rsquo;t care about the trust state; it only needs a private key.\nWhat the installer surfaces # After all this, the install script prints one of two lines:\nsigned-with: ClaudeDeck Code Signing (TCC grant survives rebuilds) or:\nsigned-with: ad-hoc (Accessibility will reset on rebuild) So a future-me who wonders why they\u0026rsquo;re re-granting Accessibility for the third time today has a one-line diagnosis: the cert isn\u0026rsquo;t being found, or wasn\u0026rsquo;t created.\nLessons # TCC stores code requirements, not app identities. \u0026ldquo;This app is allowed\u0026rdquo; hides a sharper truth: \u0026ldquo;this specific code identity is allowed\u0026rdquo;. Ad-hoc codesigning pins the designated requirement to cdhash. Cdhash changes on every rebuild. Ad-hoc plus a tight iteration loop equals Accessibility groundhog day. A self-signed Keychain cert is enough to break the loop. No Apple Developer ID required. The DR just needs a stable anchor; the cert\u0026rsquo;s leaf hash provides one. security find-identity -v -p codesigning filters too aggressively. Drop -v if you need to detect self-signed roots. codesign doesn\u0026rsquo;t care about trust state when signing. When TCC\u0026rsquo;s behavior surprises you, dump the designated requirement. codesign -d -r- shows exactly what TCC is matching against. Most of my \u0026ldquo;TCC is broken\u0026rdquo; theories evaporated when I looked at the actual DR. The full installer integration lives in docs/2026-05-10-codesign-tcc-persistence.md in the ClaudeDeck repo.\nReferences # codesign(1) man page: the --sign, -r, and -d flags. tccutil(1) man page: for resetting TCC entries when debugging (tccutil reset Accessibility \u0026lt;bundle-id\u0026gt;). Apple, \u0026ldquo;Code Signing Requirement Language\u0026rdquo;: https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/RequirementLang/RequirementLang.html Howard Oakley, \u0026ldquo;A brief history of code signing on Macs\u0026rdquo; (2025-04-26): https://eclecticlight.co/2025/04/26/a-brief-history-of-code-signing-on-macs/ Apple Developer Forums #730043, \u0026ldquo;How to handle TCC permissions\u0026rdquo;: https://developer.apple.com/forums/thread/730043 yabai wiki, \u0026ldquo;Codesigning yabai\u0026rdquo;: https://github.com/koekeishiya/yabai/wiki/Installing-yabai-(latest-release)#codesigning-yabai (same self-signed-cert pattern, predates this post by years) Bun\u0026rsquo;s non-deterministic --compile output: oven-sh/bun#29120 ClaudeDeck installer cert detection: cli/src/install.ts (findSigningCert, defaultCodesigner) ClaudeDeck longer-form walkthrough: docs/2026-05-10-codesign-tcc-persistence.md ","date":"10 May 2026","externalUrl":null,"permalink":"/posts/tcc-cdhash-trap/","section":"Posts","summary":" My daemon's preflight log said `osascript is not allowed assistive access. (-1719)`. System Settings disagreed: the entry was right there, toggled on. Spoiler: ad-hoc codesigning pins TCC's designated requirement to the binary's cdhash, and `bun build --compile` produces a different cdhash on every rebuild. Building ClaudeDeck · Part 8 of 10 1 2 3 4 5 6 7 8 9 10 I’m building a Stream Deck plugin called ClaudeDeck (Stream Deck is Elgato’s little USB grid of programmable keys with LCD displays under each one). The plugin talks to a background daemon (a long-running process that starts at login and waits for events), and that daemon needs to call System Events via AppleScript to switch Ghostty tabs (Ghostty is my terminal emulator) whenever I press a Stream Deck key. macOS gates that capability, automating other apps, through System Settings → Privacy \u0026 Security → Accessibility, the pane you’ve probably toggled for tools like Rectangle or BetterTouchTool. On first install I added the daemon, toggled it on, and got back to work.\n","title":"TCC pins your Accessibility grant to a cdhash. Every rebuild breaks it.","type":"posts"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/telemetry/","section":"Tags","summary":"","title":"Telemetry","type":"tags"},{"content":" I wrote a daemon to listen to Claude Code hooks. My first version read `$CLAUDE_HOOK_PAYLOAD` and logged empty bodies for two days straight. The payload was sitting on stdin the whole time. Building ClaudeDeck · Part 1 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS This post is the five gotchas I hit while wiring up ClaudeDeck, a Stream Deck plugin (a small program that runs inside Elgato\u0026rsquo;s Stream Deck app on the USB grid of programmable LCD keys) that talks to Claude Code over its hooks system. Claude Code is Anthropic\u0026rsquo;s terminal CLI for Claude (claude in your shell), and its hooks are user-defined scripts it spawns at certain points in a session (before a tool call, on session start, on prompt submit). My daemon is a long-running background process the plugin and the hooks both talk to over a local socket. None of the gotchas are exotic. All of them cost me hours. Each one is a place where the docs were either silent, ambiguous, or contradicted by tribal knowledge I picked up from other people\u0026rsquo;s projects.\nIf you\u0026rsquo;re building anything that reacts to Claude Code events (a logger, a permission gate, a status widget), read this first.\nSymptom: empty bodies, all the way down # The first plan was based on a design note I\u0026rsquo;d read claiming the hook payload arrives via an environment variable called $CLAUDE_HOOK_PAYLOAD. So the daemon listened on 127.0.0.1:9127/hooks/PreToolUse and the hook script was a one-liner:\n#!/bin/bash # (this is wrong) echo \u0026#34;$CLAUDE_HOOK_PAYLOAD\u0026#34; | curl -X POST http://127.0.0.1:9127/hooks/PreToolUse -d @- Sessions started. Hooks fired. The daemon log filled up with this:\nhook PreToolUse session=undefined subs=0 body-length=0 hook UserPromptSubmit session=undefined subs=0 body-length=0 $CLAUDE_HOOK_PAYLOAD was always empty. The hook was running (exit code 0, no errors, curl was reaching the daemon), but it had nothing to send.\nGotcha 1: the payload is on stdin, not in an environment variable # The payload arrives on stdin (standard input: the file descriptor a program reads from by default, the same one cat reads when you pipe into it). Claude Code spawns the command with the JSON body piped to its standard input. The script in settings.local.json (Claude Code\u0026rsquo;s per-user or per-project config file at ~/.claude/settings.local.json or \u0026lt;project\u0026gt;/.claude/settings.local.json) is responsible for reading stdin and doing whatever it likes with the JSON. No environment variable is involved.\nThe corrected shape:\n#!/bin/bash curl -sf -X POST http://127.0.0.1:9127/hooks/PreToolUse \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d @- -d @- tells curl to read the body from stdin. Claude Code pipes the JSON in, curl forwards it to the daemon, daemon parses it. Done.\nHow did I figure this out? Two paths converged. First, AgentDeck, an older Stream Deck integration for a related agent runtime, uses exactly the curl … -d @- pattern, no env var in sight. Second, I ran:\nclaude --debug hooks --debug-file /tmp/claude-hooks.log That flag isn\u0026rsquo;t in the public docs (at least not at the time of writing) but it works, and the debug log shows the hook\u0026rsquo;s stdout, stderr, exit code, and (the part I needed) the fact that the stdin pipe was active and full while my script was reading an empty env var. The flag earned its keep ten times over in the next two days.\nIf you ever find yourself debugging hooks, claude --debug hooks first, everything else second.\nWhat the JSON payload actually contains # A PreToolUse payload, formatted for reading:\n{ \u0026#34;session_id\u0026#34;: \u0026#34;01J9...\u0026#34;, \u0026#34;transcript_path\u0026#34;: \u0026#34;/path/to/transcript.jsonl\u0026#34;, \u0026#34;cwd\u0026#34;: \u0026#34;/Users/nickboy/workspace/some-project\u0026#34;, \u0026#34;hook_event_name\u0026#34;: \u0026#34;PreToolUse\u0026#34;, \u0026#34;tool_name\u0026#34;: \u0026#34;Edit\u0026#34;, \u0026#34;tool_input\u0026#34;: { \u0026#34;file_path\u0026#34;: \u0026#34;/path/to/file.ts\u0026#34;, \u0026#34;old_string\u0026#34;: \u0026#34;...\u0026#34;, \u0026#34;new_string\u0026#34;: \u0026#34;...\u0026#34; } } The events I care about for a Stream Deck plugin: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, Notification. The fields you can rely on are session_id and hook_event_name. cwd, tool_name, tool_input show up on the events that have something to say about them. transcript_path points at the JSONL of the full conversation. Useful, but expensive; it grows fast.\nThe schema isn\u0026rsquo;t formally published, so treat anything past session_id and hook_event_name as best-effort.\nGotcha 2: HTTP beats Unix sockets for the hook listener # I spent an hour considering a Unix domain socket (a file-backed inter-process socket like /tmp/foo.sock, same Berkeley socket API as TCP but no network stack) instead of HTTP. The pitch was \u0026ldquo;lower overhead, no port conflicts\u0026rdquo;. I dropped it. Reasons:\nHTTP is debuggable. curl -v and nc -zv 127.0.0.1 9127 are already installed on every box. Unix sockets need socat and a finger memory I don\u0026rsquo;t have. HTTP gives you a free WebSocket upgrade. The plugin needs a bidirectional channel to subscribe to state updates. (WebSocket is the protocol that piggybacks on an HTTP connection and then leaves it open for two-way messaging, the kind most web apps use for live chat.) Sharing the port between hooks (POST) and plugin (WebSocket on /ws) is one line of routing. HTTP gives you a free health endpoint. GET /health for claudedeck doctor is trivial. Port conflict is a non-issue. Pick a high port (9127 here), check on startup, fail loudly if taken. The daemon ends up serving three concerns from one Bun process (Bun is a JavaScript runtime like Node.js, with a built-in Bun.serve() HTTP server):\nPOST /hooks/:event # hook ingress from Claude Code POST /control/:cmd # commands from the Stream Deck plugin GET /health # health check GET /ws # WebSocket upgrade for state subscriptions One Bun process, one port, three concerns. (daemon/src/server.ts)\nGotcha 3: fire-and-forget by default, but PreToolUse is different # The naive default for hooks is fire-and-forget: the script POSTs to the daemon, the daemon returns 200 immediately, Claude Code\u0026rsquo;s hook returns success, Claude proceeds. The daemon does its real work asynchronously, after the HTTP response is already on the wire.\nThis is the right default for almost every event. PostToolUse, Stop, Notification, UserPromptSubmit, SessionStart: none of them need to block Claude. Send a 200, queue the event, move on.\nThe exception is PreToolUse, which is where Claude asks \u0026ldquo;may I run this tool?\u0026rdquo;. The hook\u0026rsquo;s response (JSON on stdout, or an exit code) decides whether the tool runs at all:\n{ \u0026#34;hookSpecificOutput\u0026#34;: { \u0026#34;hookEventName\u0026#34;: \u0026#34;PreToolUse\u0026#34;, \u0026#34;permissionDecision\u0026#34;: \u0026#34;allow\u0026#34; } } If you want to use the hook as an out-of-process permission gate (an external program, not Claude itself, decides whether the tool call runs; in my case the user presses YES/NO on the Stream Deck), the hook needs to block until the human decides. Claude Code\u0026rsquo;s per-hook timeout defaults to 600 seconds, so the hook can hold for almost ten minutes waiting for input.\nThe pattern that works:\nPreToolUse fires. Curl POSTs to the daemon. Daemon registers a pending permission. Daemon notifies the Stream Deck plugin via WebSocket. Plugin lights up the YES/NO keys. Hook blocks waiting for the daemon\u0026rsquo;s response. Curl\u0026rsquo;s --max-time 590 caps the wait safely under Claude\u0026rsquo;s 600s ceiling. User presses a key. Plugin sends permission:respond to the daemon. Daemon completes the held HTTP response. Hook stdout gets the JSON, exits 0, Claude reads the decision. That round trip, and the reasons holding an HTTP request open for 590 seconds turned out to be the wrong long-term shape, is its own post: Holding an HTTP request open for 590 seconds.\nGotcha 4: project-level settings override user-level (not merge) # You\u0026rsquo;d think hooks declared in ~/.claude/settings.local.json applies to every Claude session. It doesn\u0026rsquo;t.\nWhen you start claude inside a project directory that has its own .claude/settings.local.json, the project file overrides the user file. Not merges, overrides. If you wrote your hooks into the user file but the project already has a permissions block (very common: Claude auto-writes one on the first permission grant), your hooks won\u0026rsquo;t load for that project.\nSymptom: claudedeck doctor is green, the daemon is running, hooks are wired up at ~/.claude/settings.local.json, but the daemon\u0026rsquo;s log shows zero hook activity for sessions started inside that one project.\nFix: merge the hook entries into the project\u0026rsquo;s .claude/settings.local.json too. ClaudeDeck has an installStatusLineForProjects() helper that scans ~/workspace/* (plus a configurable list) and patches each one, and a StatuslineAutoPatcher that does the same patch on every SessionStart for any cwd it hasn\u0026rsquo;t seen before. So opening Claude inside a new project triggers a one-time auto-patch. (hooks/install.ts)\nGotcha 5: settings are read once per session # Claude Code reads settings.local.json at session start. Not during the session. If you run an install script that adds hook entries while a Claude session is already open, that session keeps its pre-install settings until it exits.\nSymptom: you just ran ./install.sh, doctor is green, but claudedeck status still says 0 sessions tracked.\nFix: open a fresh claude session. The next one picks up the new settings.\nTrivial once you know. Easy to lose an hour to if you don\u0026rsquo;t.\nWhat I\u0026rsquo;d tell my past self # The payload is on stdin. Always. No environment variable, no flag. Run claude --debug hooks to confirm what\u0026rsquo;s actually being passed before you build anything else. HTTP on 127.0.0.1:\u0026lt;port\u0026gt; beats Unix sockets for a hook listener. You get free debuggability, free health endpoint, free WebSocket upgrade for a sibling protocol. Fire-and-forget by default. Block only on PreToolUse if you\u0026rsquo;re gating permissions. Cap the block at 590 seconds to stay under Claude\u0026rsquo;s 600s timeout. Project-local settings override user-level. They do not merge. When hooks aren\u0026rsquo;t firing, check \u0026lt;project\u0026gt;/.claude/settings.local.json before you check anything else. Settings are read once per session. Existing sessions don\u0026rsquo;t pick up new hooks. Open a new one. The five gotchas above ate the better part of a week. Hopefully this saves someone else the trip.\nReferences # Claude Code hooks documentation: https://docs.claude.com/en/docs/claude-code/hooks (canonical schema for events and hookSpecificOutput) AgentDeck, the upstream HTTP+stdin pattern this work borrowed from: https://github.com/puritysb/AgentDeck/tree/master/bridge (see bridge/src/ for the curl-on-stdin hook handler) claude --debug hooks --debug-file \u0026lt;path\u0026gt;: undocumented but functional at the time of writing; discovered by running it and watching the stdin pipe behaviour land in the debug log. Not present in the public hooks reference. ClaudeDeck hook ingress, including the historical 590s permission hold: daemon/src/server.ts (POST /hooks/:event routing) ClaudeDeck statusline forwarder used for per-turn context-window telemetry: cli/src/statusline.sh Companion post on why PreToolUse waits up to 590 seconds for a key press: Holding an HTTP request open for 590 seconds Companion post on how Claude Code\u0026rsquo;s statusline doubles as a per-turn telemetry side channel: The statusline side channel ","date":"10 May 2026","externalUrl":null,"permalink":"/posts/claude-code-hooks-reality/","section":"Posts","summary":" I wrote a daemon to listen to Claude Code hooks. My first version read `$CLAUDE_HOOK_PAYLOAD` and logged empty bodies for two days straight. The payload was sitting on stdin the whole time. Building ClaudeDeck · Part 1 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS This post is the five gotchas I hit while wiring up ClaudeDeck, a Stream Deck plugin (a small program that runs inside Elgato’s Stream Deck app on the USB grid of programmable LCD keys) that talks to Claude Code over its hooks system. Claude Code is Anthropic’s terminal CLI for Claude (claude in your shell), and its hooks are user-defined scripts it spawns at certain points in a session (before a tool call, on session start, on prompt submit). My daemon is a long-running background process the plugin and the hooks both talk to over a local socket. None of the gotchas are exotic. All of them cost me hours. Each one is a place where the docs were either silent, ambiguous, or contradicted by tribal knowledge I picked up from other people’s projects.\n","title":"The Claude Code hooks docs are wrong. Here's what's actually on the wire.","type":"posts"},{"content":" Claude Code calls a custom statusline command every turn with a JSON payload on stdin. The payload includes the current context-window fill percentage, model, cost, and cwd. Nothing in the contract says you can only read it, and you can fork it to anything you want while the command stays a statusline. Building ClaudeDeck · Part 4 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Quick framing for anyone new to Claude Code: it\u0026rsquo;s Anthropic\u0026rsquo;s terminal CLI for Claude, and the statusline is the configurable line of text it prints under your prompt every turn, like a shell prompt for the agent. You point at any script in settings.local.json, Claude pipes a JSON object to it on stdin, and whatever the script writes to stdout becomes the visible line.)\nI have a Stream Deck plugin called ClaudeDeck (Stream Deck is Elgato\u0026rsquo;s USB grid of LCD keys, the kind streamers use for scene switching, and \u0026ldquo;plugin\u0026rdquo; means the small TypeScript program that runs inside Elgato\u0026rsquo;s app and draws on those keys) that paints a per-session context-window donut on each key. (Context window = the LLM\u0026rsquo;s working memory. Once you fill it, Claude starts dropping the oldest turns; /compact summarises the conversation to free space.) Green under 50%, yellow above, red past 80%, so I know when to /compact without squinting at the TUI (text user interface, Claude Code\u0026rsquo;s full-screen terminal app). The hard part wasn\u0026rsquo;t drawing the donut. It was: how does a Stream Deck plugin running in its own process know each Claude Code session\u0026rsquo;s current context fill?\nI considered polling. I considered hooks (I\u0026rsquo;ve already written about the hook layer in a separate post). Then I read the statusline docs more carefully and realised Claude Code was already calling something I controlled, every turn, with the exact JSON I wanted. The statusline command. Not a hook, not an API. The same little script that prints the line under your prompt.\nThe whole donut pipeline ended up being eight lines of bash dressed up as a statusline.\nWhat the statusline command actually receives # If you set statusLine in ~/.claude/settings.local.json to a type: \u0026quot;command\u0026quot;, Claude Code runs that command on every turn and pipes a JSON object to its stdin. The command\u0026rsquo;s stdout becomes the visible line under the prompt.\n{ \u0026#34;session_id\u0026#34;: \u0026#34;01J9...\u0026#34;, \u0026#34;cwd\u0026#34;: \u0026#34;/Users/nickboy/workspace/claudedeck\u0026#34;, \u0026#34;model\u0026#34;: { \u0026#34;display_name\u0026#34;: \u0026#34;Claude Opus 4.6\u0026#34; }, \u0026#34;cost\u0026#34;: { \u0026#34;total_cost_usd\u0026#34;: 0.234 }, \u0026#34;context_window\u0026#34;: { \u0026#34;used_tokens\u0026#34;: 124528, \u0026#34;max_tokens\u0026#34;: 200000, \u0026#34;used_percentage\u0026#34;: 62.26 } } context_window.used_percentage is exactly the number I want on the Stream Deck. No estimation, no token counting, no API round-trip. Claude already did the math and is handing it to me on a plate, once per turn. The only thing standing between me and a live donut was the realisation that the statusline\u0026rsquo;s contract doesn\u0026rsquo;t say I have to only render a string.\nThe side channel # The statusline command\u0026rsquo;s contract is \u0026ldquo;read JSON from stdin, write text to stdout\u0026rdquo;. It\u0026rsquo;s silent on whether you can also POST the JSON to a local daemon on the way through. So that\u0026rsquo;s what mine does.\n#!/bin/sh # claudedeck-statusline — Claude Code statusline command. set -e INPUT=\u0026#34;$(cat)\u0026#34; # Side channel: forward to daemon, fire-and-forget, 1 s cap. SID=\u0026#34;$(printf \u0026#39;%s\u0026#39; \u0026#34;$INPUT\u0026#34; | jq -r \u0026#39;.session_id // empty\u0026#39; 2\u0026gt; /dev/null || true)\u0026#34; if [ -n \u0026#34;$SID\u0026#34; ]; then printf \u0026#39;%s\u0026#39; \u0026#34;$INPUT\u0026#34; \\ | curl -sf -m 1 -X POST \u0026#34;http://127.0.0.1:9127/context/$SID\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ --data-binary @- \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 \u0026amp; fi # Visible: prefer the user\u0026#39;s existing statusline if present. DELEGATE=\u0026#34;${CLAUDEDECK_STATUSLINE_DELEGATE:-$HOME/.local/bin/claude-statusline}\u0026#34; if [ -x \u0026#34;$DELEGATE\u0026#34; ] \u0026amp;\u0026amp; [ \u0026#34;$DELEGATE\u0026#34; != \u0026#34;$0\u0026#34; ]; then printf \u0026#39;%s\u0026#39; \u0026#34;$INPUT\u0026#34; | \u0026#34;$DELEGATE\u0026#34; exit $? fi # ...fallback rendering omitted... The daemon (my long-running background process at 127.0.0.1:9127; see the hooks post for why it lives there) receives POST /context/:sid, parses context_window.used_percentage, updates the slot in its in-memory state, and pushes the new value over WebSocket (the always-open two-way HTTP-upgraded connection the plugin keeps to the daemon) to the Stream Deck plugin. End-to-end latency: a few milliseconds. No polling, no extra API call, no new transport. Claude Code was already going to call this command anyway.\nTwo pieces of that script are doing real work and deserve a closer look.\nBackground and cap, always # The statusline command runs on Claude Code\u0026rsquo;s hot path. Whatever it does adds to the time between you pressing Enter and seeing the prompt redraw. Three protections, all in the same line:\ncurl -sf -m 1 ... \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 \u0026amp; -m 1 caps the curl at one second. If the daemon is wedged, curl gives up and Claude moves on. \u0026amp; backgrounds it (forks the curl into a child process and immediately moves on, the standard shell pattern for \u0026ldquo;fire-and-forget\u0026rdquo;). The statusline script doesn\u0026rsquo;t wait for the POST to complete before printing its line. \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 discards curl\u0026rsquo;s output (redirects both stdout, file descriptor 1, and stderr, file descriptor 2, to the bit-bucket). Anything that leaks to stdout would appear in your statusline. Skip any of these and you eventually get a turn where the TUI hangs for a few seconds waiting on a curl that\u0026rsquo;s never coming back. That\u0026rsquo;s the trap. The visible statusline must render even when the side channel is on fire.\nDelegate, don\u0026rsquo;t replace # I\u0026rsquo;d been running a much richer custom statusline for months: model name, git branch, agent type, cost in yellow when it crosses a threshold. Replacing it with my minimal version was a regression I noticed within five minutes of installing my own tool.\nThe fix was to delegate. If ~/.local/bin/claude-statusline exists and is executable, my script pipes the same stdin to it and exits with its status. The side channel still ran (it happens before the delegate call), so the daemon gets the payload either way. Override via CLAUDEDECK_STATUSLINE_DELEGATE if your delegate lives somewhere weird.\nThe project-vs-user override that ate an afternoon # I shipped this, ran ./install.sh, opened a Claude session in the ClaudeDeck repo. Donut went live. Opened another Claude session in my Obsidian vault at ~/Documents/obsidian/MyVault/. Donut: stuck at 0%.\nThe daemon log showed no POST /context/... requests from that session at all. So the statusline command wasn\u0026rsquo;t being invoked. The user-level ~/.claude/settings.local.json had it. Why wasn\u0026rsquo;t Claude reading it?\nBecause Claude Code\u0026rsquo;s project-local .claude/settings.local.json doesn\u0026rsquo;t merge with the user-level file. It overrides it. Wholesale. Every project where I\u0026rsquo;d ever clicked \u0026ldquo;Always allow\u0026rdquo; for a tool had a project-local settings file with a permissions section and no statusLine section. The user-level statusLine was being shadowed by a file that didn\u0026rsquo;t even mention it.\nThis is the same shape as the gotcha I hit in the hooks layer: the same settings model, the same override semantics, the same hours-of-debugging surprise. If your config lives only in one place, you\u0026rsquo;re going to miss the half of projects that have their own version of that place.\nThe fix has two parts, and both of them are running today.\nInstall-time patching. The installer walks ~/workspace/* (configurable via CLAUDEDECK_PATCH_PROJECTS) and patches every project\u0026rsquo;s .claude/settings.local.json to add the statusline command. If a project already has a non-claudedeck statusLine, it\u0026rsquo;s left alone; we recognise our own by the claudedeck-statusline substring in the path. Anything else is somebody\u0026rsquo;s hand-rolled config and not ours to touch.\nRun-time patching. Workspaces aren\u0026rsquo;t the only place I run claude from. Obsidian vaults, personal scripts, one-off side projects in ~/code. So the daemon also runs a StatuslineAutoPatcher on every SessionStart hook (one of Claude Code\u0026rsquo;s hook events, fires when a new Claude session boots in a given working directory): on first session in a new project, patch that project\u0026rsquo;s settings file. The current session won\u0026rsquo;t pick it up, because Claude reads settings once at session start, before the hook fires, but the next session in that project will. You pay the \u0026ldquo;first session is dumb\u0026rdquo; cost exactly once per new project.\nexport class StatuslineAutoPatcher { private readonly seen = new Set\u0026lt;string\u0026gt;(); maybePatch(cwd: string): \u0026#34;patched\u0026#34; | \u0026#34;skipped\u0026#34; | \u0026#34;noop\u0026#34; | \u0026#34;no-file\u0026#34; | \u0026#34;cached\u0026#34; { if (!cwd) return \u0026#34;cached\u0026#34;; if (this.seen.has(cwd)) return \u0026#34;cached\u0026#34;; this.seen.add(cwd); return this.patcher(cwd, this.scriptPath, (m) =\u0026gt; console.log(m)); } } The in-memory seen set dedupes: after the first SessionStart in a project, we don\u0026rsquo;t reopen its settings file on every subsequent session. Daemon restart re-checks every project, which is also when stale paths (e.g. a previous install that pointed at a tmpdir) get healed.\nThe patcher itself is idempotent. It reads the settings file, recognises our entry by the claudedeck-statusline substring, and either replaces a stale path, installs a fresh entry into a file that doesn\u0026rsquo;t have one, or backs off if the user has their own custom statusLine. Survives Claude itself rewriting the file\u0026rsquo;s permissions section, because we only ever touch statusLine.\nThree rules for safe side-channeling # This pattern generalises. Anything you want to ship per-turn (Prometheus metrics, cost-per-project dashboards, an editor-side \u0026ldquo;context filling up\u0026rdquo; toast) can use the statusline command as the cheapest hook into Claude Code\u0026rsquo;s per-turn state. Three rules to keep it safe:\nBackground the side channel. Never block the visible statusline on a network call, a file write, or a subprocess. \u0026amp; it. The statusline runs on the user\u0026rsquo;s turn loop; a wedged side channel turns into a wedged prompt. Cap the timeout. -m 1 on curl, equivalent on whatever else. If the daemon is down, the statusline command must still render something within a second. The threshold doesn\u0026rsquo;t have to be one second, just lower than your patience for a prompt redraw. Delegate to the user\u0026rsquo;s existing statusline if they have one. Detect their script, pipe stdin through, exit with its status. Overwriting somebody\u0026rsquo;s polished statusline with your minimal version is the fastest way to get uninstalled. Lessons # The cheapest hook into Claude Code\u0026rsquo;s per-turn state is the one that\u0026rsquo;s already running. Claude is already calling your statusline command every turn with the JSON you want. Don\u0026rsquo;t reach for a new API or a polling loop. Fork the call. Project-local settings override user-level wholesale. Not merge. Override. If your config installs only in one place, half your sessions won\u0026rsquo;t see it. Patch project files at install time and on SessionStart. The visible statusline must render on a failed side channel. Cap, background, swallow stderr. The user\u0026rsquo;s prompt is sacred; your telemetry isn\u0026rsquo;t. Delegate the visible render to whatever the user already has. It\u0026rsquo;s polite, and it costs you one extra if [ -x ... ] check. Recognise your own installs by a substring in the command path. Then your install/uninstall is idempotent, and you never clobber a config that wasn\u0026rsquo;t yours. References # Claude Code statusline docs: https://docs.claude.com/en/docs/claude-code/statusline (the JSON payload schema piped to the statusline command\u0026rsquo;s stdin; URL redirects to https://code.claude.com/docs/en/statusline, both resolve) Claude Code settings docs: https://docs.claude.com/en/docs/claude-code/settings (project-level .claude/settings.local.json precedence over user-level) ClaudeDeck\u0026rsquo;s statusline forwarder: cli/src/statusline.sh (side-channel POST plus delegate-or-fallback rendering) ClaudeDeck\u0026rsquo;s SessionStart auto-patcher: daemon/src/statuslineAutoPatch.ts (in-memory dedup, calls patchProjectSettingsAt) ClaudeDeck\u0026rsquo;s settings patcher: hooks/install.ts (patchProjectSettingsAt, installStatusLineForProjects, recognition rule for our own installs) The hook-layer twin of this post (same project-vs-user override at the hook layer): The Claude Code hooks docs are wrong. Here\u0026rsquo;s what\u0026rsquo;s actually on the wire. ","date":"10 May 2026","externalUrl":null,"permalink":"/posts/statusline-side-channel/","section":"Posts","summary":" Claude Code calls a custom statusline command every turn with a JSON payload on stdin. The payload includes the current context-window fill percentage, model, cost, and cwd. Nothing in the contract says you can only read it, and you can fork it to anything you want while the command stays a statusline. Building ClaudeDeck · Part 4 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Quick framing for anyone new to Claude Code: it’s Anthropic’s terminal CLI for Claude, and the statusline is the configurable line of text it prints under your prompt every turn, like a shell prompt for the agent. You point at any script in settings.local.json, Claude pipes a JSON object to it on stdin, and whatever the script writes to stdout becomes the visible line.)\n","title":"The Claude Code statusline is a per-turn telemetry side channel","type":"posts"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/tty/","section":"Tags","summary":"","title":"Tty","type":"tags"},{"content":" Two undocumented behaviours in the Elgato Stream Deck SDK ate most of a weekend: a per-key title-alignment cache that silently ignores manifest updates, and a `willAppear` event that doesn't always re-fire after a plugin restart. The fixes are short. Finding them was not. Building ClaudeDeck · Part 2 of 10 1 2 3 4 5 6 7 8 9 10 (Skip this paragraph if you\u0026rsquo;ve shipped a Stream Deck plugin before. The Stream Deck is Elgato\u0026rsquo;s USB grid of programmable LCD keys, common on streamer desks for scene switching. A \u0026ldquo;plugin\u0026rdquo; is a small program (TypeScript, in my case) that runs as a child process of Elgato\u0026rsquo;s Stream Deck app, registers one or more actions the user can drag onto keys, and reacts to events like \u0026ldquo;key pressed\u0026rdquo; or \u0026ldquo;key visible.\u0026rdquo; The SDK is @elgato/streamdeck from npm. A manifest is a manifest.json next to the plugin that declares its actions, supported devices, default icons, and per-state defaults like title alignment.)\nI built a Stream Deck plugin in TypeScript using the official @elgato/streamdeck SDK. Most of it is pleasant: register actions in manifest.json, implement onWillAppear (the lifecycle event the SDK fires when a key with your action attached becomes visible on the device) / onKeyDown, call setTitle() and setImage(), ship. Then I hit two cases where the device or the app lies about its state, and I spent a weekend staring at a deck of mysteriously-wrong buttons.\nThis is the post I wish I\u0026rsquo;d found in week one.\nThe keys above are the result of fixing both quirks: the session slots in row 1 each render as a single SVG (donut, percentage, name baked together, which is Quirk 1\u0026rsquo;s workaround), and the page-navigation arrows in row 3 use the SDK\u0026rsquo;s built-in actions wired up by a generated profile (Quirk 2\u0026rsquo;s territory).\nQuirk 1: TitleAlignment is per-key cached, not manifest-driven # My session-slot keys needed three text layers stacked vertically: session name at the top, a context-usage donut in the middle, a percentage centered inside the donut. The obvious split: title for the top text, SVG image for the rest.\nThe SDK\u0026rsquo;s manifest lets you control title alignment, font size, and colour per action state:\n{ \u0026#34;UUID\u0026#34;: \u0026#34;com.nickboy.claudedeck.sessionSlot\u0026#34;, \u0026#34;Name\u0026#34;: \u0026#34;Session Slot\u0026#34;, \u0026#34;States\u0026#34;: [{ \u0026#34;TitleAlignment\u0026#34;: \u0026#34;top\u0026#34;, \u0026#34;FontSize\u0026#34;: 10, \u0026#34;ShowTitle\u0026#34;: true }] } Works fine the first time you drag the action onto a key. Now bump FontSize: 10 to FontSize: 9 because 10 turned out cramped, reload the plugin, and watch nothing change. Existing keys hold whatever values they had the first time the action was placed. The Stream Deck app caches per-key title settings; subsequent manifest reloads don\u0026rsquo;t override that cache. The manifest only seeds the initial values for new placements.\nYou can see this directly in the app\u0026rsquo;s per-key UI. The dropdowns for alignment, font size, and colour show whatever was last written to that key, not whatever the manifest says today. There\u0026rsquo;s no \u0026ldquo;reset from manifest\u0026rdquo; button.\nWhat didn\u0026rsquo;t work # First instinct: hardcode the title position by sending setTitle with leading or trailing newlines to shove the text up or down. The title is rendered by the device firmware, not the SDK. Newlines render as literal blank lines, but the firmware still vertically centres the whole block (blank lines included) inside the title region. You can\u0026rsquo;t push it to the top without trashing the layout.\nSecond instinct: ask every user to fix their key settings by hand. Friction-y, brittle, hard to document, hard to keep in sync when the layout evolves.\nWhat works: stop using titles # The fix is to render the entire key as an SVG via setImage() and set setTitle(\u0026quot;\u0026quot;) so the title region is empty. Nothing depends on the cached alignment any more. The SDK passes the SVG bytes through to the device firmware as a 72×72 image; the firmware blits it pixel-for-pixel (\u0026ldquo;blit\u0026rdquo; being the old graphics term for \u0026ldquo;copy this rectangle of pixels straight to the framebuffer without scaling or transforming it\u0026rdquo;).\nThe shape of the renderer (full code in plugin/src/actions/sessionSlot.ts):\nfunction renderSessionSlotImage(state: SessionState): string { const { backgroundHex, displayName, contextFillPercent, contextFillColor } = state; const nameOverlay = displayName ? `\u0026lt;text x=\u0026#34;36\u0026#34; y=\u0026#34;14\u0026#34; font-family=\u0026#34;-apple-system, sans-serif\u0026#34; font-size=\u0026#34;11\u0026#34; font-weight=\u0026#34;600\u0026#34; fill=\u0026#34;#fff\u0026#34; text-anchor=\u0026#34;middle\u0026#34;\u0026gt;${escape(displayName)}\u0026lt;/text\u0026gt;` : \u0026#34;\u0026#34;; const donut = renderDonut(contextFillPercent, contextFillColor); const svg = `\u0026lt;svg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; viewBox=\u0026#34;0 0 72 72\u0026#34;\u0026gt;` + `\u0026lt;rect width=\u0026#34;72\u0026#34; height=\u0026#34;72\u0026#34; fill=\u0026#34;${backgroundHex}\u0026#34;/\u0026gt;` + `${donut}${nameOverlay}\u0026lt;/svg\u0026gt;`; return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; } // In onWillAppear / state update: await ev.action.setImage(renderSessionSlotImage(state)); await ev.action.setTitle(\u0026#34;\u0026#34;); // empty so it doesn\u0026#39;t compete with the image One subtlety: data:image/svg+xml;utf8, with encodeURIComponent is smaller and faster than base64 for SVG payloads, and the Stream Deck SDK accepts both. (A data URI is a URL that inlines the resource into itself, using the format data:\u0026lt;mime\u0026gt;;\u0026lt;encoding\u0026gt;,\u0026lt;bytes\u0026gt;, instead of pointing at a file or HTTP endpoint. Base64 encodes binary into ASCII at +33% size; URL-encoded SVG keeps most characters as-is and only escapes the ones URLs can\u0026rsquo;t contain.) I default to URL-encoded.\nThe bonus prize is full typographic control. The native title path gives you one font, one size, one colour per state. Inside an SVG you mix sizes, weights, colours, and arbitrary shapes. The donut, the percentage, and the session name all live in one image, with no fighting the title region and no per-key cache to drift.\nI now do this for every action that needs any layout sophistication: session slots, Plan Usage, the arrow keys with offline-indicator dots. The native title path is reserved for the simplest actions (a placeholder that just shows bridge state, where the manifest defaults are fine forever).\nQuirk 2: willAppear doesn\u0026rsquo;t always re-fire after a plugin restart # The Stream Deck SDK fires willAppear when a key becomes visible. You\u0026rsquo;d think \u0026ldquo;becomes visible after a plugin restart\u0026rdquo; counts. It usually does. Not always.\nI run streamdeck restart com.nickboy.claudedeck (the Elgato CLI\u0026rsquo;s \u0026ldquo;kill and re-spawn my plugin\u0026rdquo; command, keyed by my plugin\u0026rsquo;s reverse-DNS UUID) constantly during development. Sometimes after the restart, my plugin\u0026rsquo;s action map is empty: zero willAppear events arrived. The keys still show their last-rendered image (the firmware caches it), but the plugin has no handle to push updates. The keys are still live; pressing them fires keyDown correctly. Only willAppear is missing.\nSymptom in my plugin log:\n[plugin] bridge connected [plugin] action count: 0 [plugin] handling permission:pending → no action found, skipping The Stream Deck app\u0026rsquo;s per-plugin state console (the gear-icon panel) confirms visible=0 for the plugin, even when the ClaudeDeck profile is currently displayed and the keys are right there on the device.\nThe workaround # Toggling the profile off and back on forces the SDK to re-fire willAppear for every visible key. My dev loop:\nstreamdeck restart com.nickboy.claudedeck. Stream Deck app → profile dropdown → switch to any other profile, then back to ClaudeDeck. Plugin log: action count: 15. Done. Annoying. The plugin-internal half of the mitigation: on a daemon:ready WebSocket message (which the plugin receives on every reconnect to my daemon), I clear the session-slot manager so stale slots from a previous daemon process don\u0026rsquo;t linger. That handles the state-side refresh. The device-visible image refresh still needs the manual toggle.\nWhat I\u0026rsquo;d like instead # A \u0026ldquo;force re-emit willAppear for everything visible\u0026rdquo; RPC. Or an idempotent getVisibleActions() query so I can rebuild the action map without waiting for events. Or, best of all, just consistently firing willAppear on plugin restart.\nThis is Stream Deck app behaviour, not plugin behaviour. The SDK passes through whatever the app decides to emit. So the fix is either upstream or a workaround on my side. The cheapest workaround is documentation: a README note telling users to toggle the profile if updates aren\u0026rsquo;t landing after a plugin restart.\nA third quirk worth mentioning: profiles ship as files, with a UUID gotcha # If your plugin needs more than three or four actions, you don\u0026rsquo;t want users dragging them onto keys by hand. You ship a pre-arranged .streamDeckProfile directory tree inside the .sdPlugin bundle (the directory that is your plugin: manifest.json + binary + assets, all named with the Elgato-mandated .sdPlugin suffix). The Stream Deck app picks it up when the plugin installs.\nThe profile is a directory, not a zip, and its layout is more involved than the obvious shape. From plugin/scripts/generateProfile.ts, the shape that actually imports on Stream Deck app 6.x:\n\u0026lt;outerUuid\u0026gt;.sdProfile/ ├── manifest.json ← Version: \u0026#34;3.0\u0026#34;, AppIdentifier, Pages.Current └── Profiles/ └── \u0026lt;PAGE-UUID-UPPERCASE\u0026gt;/ └── manifest.json ← Controllers, Actions Two distinct UUIDs. The outer one names the wrapper directory. The inner page UUID names the directory under Profiles/ and is referenced (lowercased) by Pages.Current in the outer manifest. The inner directory name is uppercased; the reference is lowercased. Mess up either and the app refuses to import.\nThe grid coordinates inside the inner manifest.json (\u0026quot;0,0\u0026quot;, \u0026quot;col,row\u0026quot;) place each action. My layout for a 15-key MK.2:\nRow 0: 5 session slots. Row 1: YES / NO / ALWAYS permission keys + Wispr Flow trigger + Plan Usage. Row 2: page-prev, arrow up, enter, arrow down, page-next. The page-prev and page-next keys use system actions rather than my own: com.elgato.streamdeck.page.previous and com.elgato.streamdeck.page.next. They live inside the Stream Deck app and you drop them into your profile manifest just like your own UUIDs. I confirmed these IDs by inspecting my existing profile under ~/Library/Application Support/com.elgato.StreamDeck/ProfilesV2/. Elgato doesn\u0026rsquo;t appear to publish a system-action UUID reference, but the IDs are stable across app versions on my machine.\nThe UUID gotcha: both the outer and the page UUID must be real UUID-v4s (the 128-bit random-bytes flavour of universally-unique identifier, i.e., what you get out of uuidgen on macOS or crypto.randomUUID() in JS). The Stream Deck app uses them as keys in its profile registry. Reuse the same UUID across plugin versions and the app refuses to import the new profile because it thinks one already exists. My build script generates fresh UUIDs on every run and zips the result into a .streamDeckProfile. The Makefile target wires it into make plugin so every build produces a fresh profile.\nLessons # For any non-trivial key layout, render the whole key as an SVG via setImage() and call setTitle(\u0026quot;\u0026quot;). The per-key title cache will outlive your manifest changes; SVG won\u0026rsquo;t. If willAppear doesn\u0026rsquo;t fire after a plugin restart, toggle the profile. It\u0026rsquo;s an app-side state-machine quirk, not your code. Treat the manifest as a one-shot seed, not a config file. Settings the user can edit in the per-key UI override the manifest forever after. Ship a .streamDeckProfile if your plugin has more than three actions. Nobody drags 15 actions onto 15 keys for fun. Use Elgato\u0026rsquo;s built-in system actions for page nav, brightness, multi-action. Reimplementing them is wasted work, and the IDs (e.g. com.elgato.streamdeck.page.previous) drop straight into your profile manifest. Generate fresh UUIDs on every build. The app\u0026rsquo;s profile registry keys on them; reused UUIDs silently block imports. Building Stream Deck plugins is mostly pleasant. The two quirks above ate the most time of anything in the project, and both have one-line workarounds once you know what\u0026rsquo;s happening. Hope this saves the next person the dig.\nReferences # Stream Deck SDK getting-started docs: https://docs.elgato.com/streamdeck/sdk/introduction/getting-started/ Stream Deck SDK manifest reference (the source of truth for TitleAlignment, FontSize, ShowTitle, action States): https://docs.elgato.com/streamdeck/sdk/references/manifest/ ClaudeDeck session-slot renderer (full SVG-via-setImage implementation): plugin/src/actions/sessionSlot.ts ClaudeDeck profile generator (.streamDeckProfile layout, UUID handling): plugin/scripts/generateProfile.ts The \u0026ldquo;render the whole key as SVG via setImage\u0026rdquo; pattern: I picked this up from inspecting other open-source Stream Deck plugins where the manifest sets ShowTitle: false and the action emits image data on every redraw. The Elgato manifest reference linked above documents the ShowTitle option that makes this configuration possible. The com.elgato.streamdeck.page.previous and com.elgato.streamdeck.page.next system-action UUIDs: I did not find these documented publicly. I confirmed them by inspecting profiles exported from the Stream Deck app and by reading profiles stored under ~/Library/Application Support/com.elgato.StreamDeck/ProfilesV2/ on my machine. ","date":"10 May 2026","externalUrl":null,"permalink":"/posts/streamdeck-sdk-quirks/","section":"Posts","summary":" Two undocumented behaviours in the Elgato Stream Deck SDK ate most of a weekend: a per-key title-alignment cache that silently ignores manifest updates, and a `willAppear` event that doesn't always re-fire after a plugin restart. The fixes are short. Finding them was not. Building ClaudeDeck · Part 2 of 10 1 2 3 4 5 6 7 8 9 10 (Skip this paragraph if you’ve shipped a Stream Deck plugin before. The Stream Deck is Elgato’s USB grid of programmable LCD keys, common on streamer desks for scene switching. A “plugin” is a small program (TypeScript, in my case) that runs as a child process of Elgato’s Stream Deck app, registers one or more actions the user can drag onto keys, and reacts to events like “key pressed” or “key visible.” The SDK is @elgato/streamdeck from npm. A manifest is a manifest.json next to the plugin that declares its actions, supported devices, default icons, and per-state defaults like title alignment.)\n","title":"Two Stream Deck SDK quirks that cost me a weekend","type":"posts"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/typescript/","section":"Tags","summary":"","title":"Typescript","type":"tags"},{"content":" The hype says anyone can code now. The reality: vibe coding changes *what* senior engineers do, not *whether* we're needed. And the gap between experienced and inexperienced developers is getting wider, not narrower. Collins Dictionary named \u0026ldquo;vibe coding\u0026rdquo; their Word of the Year for 2025. Search interest spiked over 6,000%. The narrative is seductive: describe what you want, AI writes the code, programming becomes as easy as having a conversation.\nI\u0026rsquo;ve spent over a year using these tools professionally: production features, CI pipelines, and this site. Some of the hype is right. A lot of it isn\u0026rsquo;t.\nWhat Vibe Coding Actually Changes # The shift is in the feedback loop, not the typing speed. Before AI tools, you had an idea, researched how to implement it, wrote the code, debugged it, iterated. For a non-trivial feature, that cycle ran hours.\nWith vibe coding, the cycle compresses: describe, review, iterate. And the review step is where all the skill lives. You need to:\nRecognize when generated code has subtle bugs Evaluate whether an approach will scale Spot security holes in code that looks correct Know when the AI is confidently wrong Senior engineering skills, all of them. You can\u0026rsquo;t review what you don\u0026rsquo;t understand.\nWhere Vibe Coding Shines # What it\u0026rsquo;s genuinely good at:\nBoilerplate and Scaffolding # New projects, config files, CI/CD pipelines. The work is well-documented, pattern-heavy, tedious, and there\u0026rsquo;s almost always a clear \u0026ldquo;right answer\u0026rdquo; sitting in the training data.\nSetting up Cloudflare Pages deployment for this site, Claude Code generated a working GitHub Actions workflow on the first try. That\u0026rsquo;s 30-45 minutes of docs-reading I didn\u0026rsquo;t do.\nTest Generation # Describe the behavior you want to test, get reasonable test cases. Often surprisingly good at edge cases I wouldn\u0026rsquo;t have thought of. I use it most heavily for the scaffolding of test suites: assertions still get a human review, but the structural typing is gone.\nDocumentation and Content # Docstrings, README sections, code comments derived from existing code. The AI reads the implementation and describes what it does faster than I can type it.\nLearning New Frameworks # Adopting the Blowfish theme for Hugo, I explored its shortcodes and config options conversationally instead of reading documentation linearly. \u0026ldquo;I want a timeline component showing my workflow\u0026rdquo; gave back working code that taught me the API in the process.\nWhere Vibe Coding Fails # Sometimes it just doesn\u0026rsquo;t work. Sometimes it fails in dangerous ways.\nArchitecture Decisions # The biggest risk. AI tools will happily generate code for whatever architecture you describe, even when that architecture is wrong for the use case. They optimize for \u0026ldquo;does this work?\u0026rdquo; rather than \u0026ldquo;is this the right approach?\u0026rdquo; When I started this site, I had to decide: Hugo or Next.js? Content pages or data templates for the experience sections? Single config file or Blowfish\u0026rsquo;s multi-file approach? These decisions have cascading consequences. An AI can implement any of them. It can\u0026rsquo;t tell you which one you\u0026rsquo;ll regret in six months.\nSecurity # Generated code takes the happy path. Often it doesn\u0026rsquo;t sanitize inputs, doesn\u0026rsquo;t handle authentication edge cases, doesn\u0026rsquo;t follow least-privilege. Shipping unreviewed AI-generated code into production is a liability.\nPerformance Optimization # AI tools produce code that works, not code that\u0026rsquo;s fast. They don\u0026rsquo;t profile your application or understand your data access patterns. They won\u0026rsquo;t tell you about the N+1 query that\u0026rsquo;s going to tank your API at scale. Performance is system-shaped reasoning, exactly the kind AI tools struggle with.\nDebugging Complex Issues # Race conditions, subtle state management bugs, infrastructure quirks: when something goes wrong in a way that isn\u0026rsquo;t a simple syntax error, you need deep understanding of the system. AI tools can answer \u0026ldquo;what does this error message mean?\u0026rdquo; They can\u0026rsquo;t replace the mental model you\u0026rsquo;ve built over years of working with that system.\nThe Skill Shift: Writing Code to Directing AI # A year in, here\u0026rsquo;s what changed in my own work.\nI write less code. Keystroke count is probably down 40-50%. Output hasn\u0026rsquo;t dropped. If anything, I ship more features.\nI review more code. Every AI-generated change gets the same scrutiny I\u0026rsquo;d give a junior developer\u0026rsquo;s pull request. More, honestly, because a junior developer at least understands the codebase they\u0026rsquo;re touching.\nI make more decisions. With the implementation bottleneck reduced, decision-making becomes the constraint: what to build, how to structure it, which tradeoffs to accept. Skills that take years to develop.\nI communicate more precisely. Writing good prompts turns out to look a lot like writing good technical specs. You\u0026rsquo;re clear about requirements, constraints, expected behavior. Vague specs produce vague results, from humans and AI alike.\nSenior engineers are becoming more like technical leads even when they\u0026rsquo;re working solo. You direct the work, review the output, make the architectural calls. The AI handles the typing. The METR Study: Faster Feels, Slower Results # In 2025, METR (Model Evaluation \u0026amp; Threat Research) ran a study that landed harder than expected: experienced developers using AI coding tools were 19% slower on real-world tasks but felt 20% faster.\nWhy the disconnect?\nContext-switching cost: Reviewing AI output, correcting mistakes, iterating on prompts is time that doesn\u0026rsquo;t feel like \u0026ldquo;work.\u0026rdquo; False starts: AI-generated code sometimes takes you down a wrong path, and backtracking erases the time savings. Quality gaps: Code that \u0026ldquo;works\u0026rdquo; on the first try often needs revision for edge cases, error handling, and production readiness. Confidence bias: Getting code instantly feels fast, even when the total cycle time (prompt → review → fix → test) ran longer. This doesn\u0026rsquo;t make AI tools useless. The value just isn\u0026rsquo;t raw speed; it\u0026rsquo;s the type of tasks that become feasible. I wouldn\u0026rsquo;t have attempted a full Hugo site with custom theme configuration, CI/CD, and a dozen interlinked blog posts in a weekend without AI assistance. The absolute time per task may not drop. The ambition ceiling rises.\nquadrantChart title When to Vibe Code vs Hand-Code x-axis Low Complexity --\u003e High Complexity y-axis Low Familiarity --\u003e High Familiarity quadrant-1 Hand-code quadrant-2 Either approach works quadrant-3 Vibe code quadrant-4 Vibe code with careful review My Framework: When to Vibe Code # After a year of daily use, my decision process looks like this.\nVibe code when:\nThe task is well-defined with clear inputs and outputs Established patterns or documentation exist to draw from Blast radius of a mistake is low (config files, tests, documentation) You\u0026rsquo;re scaffolding something new, not modifying something complex You understand the domain well enough to review the output effectively Hand-code when:\nThe task requires deep understanding of existing system behavior Security or performance are critical You\u0026rsquo;re debugging something subtle The architectural approach isn\u0026rsquo;t settled You need to think through the problem, not just implement a solution A simpler rule: if you wouldn\u0026rsquo;t trust a junior developer\u0026rsquo;s PR for this task without extensive review, don\u0026rsquo;t trust AI-generated code either. If you would, that\u0026rsquo;s a great vibe-coding candidate.\nPredictions for 2027 and Beyond # Reading from the trajectory so far:\nContext windows will keep growing. The biggest limitation of current tools is losing context in long sessions. As context windows expand, the range of tasks suitable for vibe coding grows with them.\nReview tools will emerge. Right now, reviewing AI-generated code uses the same tools as reviewing human code. Expect specialized tools that surface common AI failure modes: security anti-patterns, performance pitfalls, outdated APIs.\nThe skill premium shifts. The most valuable engineering skill becomes the ability to evaluate, direct, and integrate AI output into production systems. System design, not syntax knowledge.\nHybrid workflows become standard. The \u0026ldquo;AI vs. no AI\u0026rdquo; debate will sound as quaint as \u0026ldquo;IDE vs. text editor.\u0026rdquo; Every engineer will use AI tools. The differentiator: how effectively.\nJunior engineers face a steeper learning curve. Skip the fundamentals and rely on AI from day one, and you\u0026rsquo;ll lack the knowledge needed to review AI output. The engineers who\u0026rsquo;ll thrive learn to code and then learn to direct AI.\nVibe coding is a leverage multiplier. It amplifies whatever skills you already bring. For senior engineers with strong fundamentals, system design experience, and judgment, it\u0026rsquo;s a genuine superpower. For anyone hoping it replaces the need to understand software engineering, that\u0026rsquo;s the hype talking.\nMy daily workflow with Claude Code is detailed in this post, and I compare the major tools in my 2026 tool comparison.\n","date":"10 May 2026","externalUrl":null,"permalink":"/posts/vibe-coding-senior-engineer/","section":"Posts","summary":" The hype says anyone can code now. The reality: vibe coding changes *what* senior engineers do, not *whether* we're needed. And the gap between experienced and inexperienced developers is getting wider, not narrower. Collins Dictionary named “vibe coding” their Word of the Year for 2025. Search interest spiked over 6,000%. The narrative is seductive: describe what you want, AI writes the code, programming becomes as easy as having a conversation.\n","title":"Vibe Coding Won't Replace Senior Engineers. It Amplifies Them.","type":"posts"},{"content":"","date":"10 May 2026","externalUrl":null,"permalink":"/tags/vibe-coding/","section":"Tags","summary":"","title":"Vibe-Coding","type":"tags"},{"content":" I press the Stream Deck key. The daemon logs the press, synthesizes `Cmd+Opt+;` through CoreGraphics, and exits cleanly. Wispr Flow does nothing. Three Apple subsystems and one decompiled Electron bundle later, the working trigger turned out to be a one-line URL. Building ClaudeDeck · Part 9 of 10 1 2 3 4 5 6 7 8 9 10 The plan was the boring kind: Stream Deck key (the physical button on Elgato\u0026rsquo;s programmable USB grid) → WebSocket message → my daemon (long-running background process) → synthesized global hotkey → Wispr Flow\u0026rsquo;s hands-free dictation starts (Wispr Flow is the voice-to-text Mac app that types your speech into the focused window) → I talk → words show up in my editor. I\u0026rsquo;d done variants of this with osascript (macOS\u0026rsquo;s command-line AppleScript runner) years ago. Should have taken an afternoon.\nIt took three days. And the eventual fix bypassed keystroke synthesis entirely, by routing through an Electron URL handler (Electron apps like Slack, Discord, VS Code, and Wispr Flow are Chromium plus Node bundled into a desktop binary, and they often register custom URL schemes like slack://) that Wispr Flow doesn\u0026rsquo;t publicly document but ships in every release.\nThis is the story of why hotkey synthesis is, on macOS 26.5 (codename Tahoe), a dead end for unsigned daemons, and how to find the side door that nearly every Electron app leaves open. (For the related \u0026ldquo;why does my macOS Accessibility grant keep resetting\u0026rdquo; story on the same daemon, see the TCC cdhash trap.)\nThe symptom # Logs from the daemon:\n[wispr] press received, synthesizing Cmd+Opt+; [wispr] CGEventPost done, 6 events posted, eventStateId=1 Logs from Wispr Flow: nothing. The hands-free indicator never appears. The transcription never starts.\nA real keypress of the same combo opens hands-free immediately. So Wispr Flow\u0026rsquo;s listener works; my events aren\u0026rsquo;t reaching it.\nPlan A: AppleScript # The lowest-friction attempt. AppleScript via osascript has been the macOS scripting hammer for two decades.\ntell application \u0026#34;System Events\u0026#34; key code 41 using {command down, option down} end tell osascript returns 0. Wispr Flow doesn\u0026rsquo;t react.\nI half-expected this. System Events injects events from a different layer than the real keyboard: it goes through AppleEvents (Apple\u0026rsquo;s decades-old inter-app message bus, the thing AppleScript actually runs on) into a high-level \u0026ldquo;post this to the focused app\u0026rdquo; path. Wispr Flow registers its hotkey via Carbon\u0026rsquo;s RegisterEventHotKey (Carbon is Apple\u0026rsquo;s pre-2007 C API layer; lots of it is deprecated but RegisterEventHotKey is still the standard \u0026ldquo;register a global keyboard shortcut\u0026rdquo; function), which sits much lower in the stack. The synthesized event never reaches the matcher.\nNot great, but Plan A was always a long shot. On to CoreGraphics.\nPlan B: CGEventPost via bun:ffi # CoreGraphics (the macOS framework behind everything that draws on screen, including event injection) is supposed to inject events early enough that even Carbon hotkeys see them. I wired up the six-event sequence (Cmd↓, Opt↓, ;↓, ;↑, Opt↑, Cmd↑) through Bun\u0026rsquo;s :ffi binding to the framework. (FFI, or \u0026ldquo;Foreign Function Interface\u0026rdquo;, is the standard way for a high-level language to call C functions in a shared library; dlopen opens the .dylib, the schema tells Bun the argument types.)\nimport { dlopen, FFIType, ptr, CString } from \u0026#34;bun:ffi\u0026#34;; const cg = dlopen( \u0026#34;/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics\u0026#34;, { CGEventCreateKeyboardEvent: { args: [\u0026#34;ptr\u0026#34;, \u0026#34;u16\u0026#34;, \u0026#34;i32\u0026#34;], returns: \u0026#34;ptr\u0026#34; }, CGEventPost: { args: [\u0026#34;i32\u0026#34;, \u0026#34;ptr\u0026#34;], returns: \u0026#34;void\u0026#34; }, CGEventSetFlags: { args: [\u0026#34;ptr\u0026#34;, \u0026#34;u64\u0026#34;], returns: \u0026#34;void\u0026#34; }, CFRelease: { args: [\u0026#34;ptr\u0026#34;], returns: \u0026#34;void\u0026#34; }, }, ); const SEMICOLON = 0x29; // virtual keycode for ; const FLAG_CMD = 0x100000; const FLAG_OPT = 0x080000; const TAP_HID = 0; // kCGHIDEventTap The pattern is canonical. skhd, Hammerspoon, and yabai (popular macOS hotkey and window-management daemons that ship as open-source reference implementations of this exact approach) all do it the same way. I added 30 ms delays between events. I set kCGEventSourceStateHIDSystemState=1 so the modifier-state table (the one macOS keeps for \u0026ldquo;is Cmd currently held down\u0026rdquo;) looked right. I verified each event had the cmd+alt flags before posting.\nWispr Flow: still nothing.\nBut (and this is the part that wasted a full day) a bare key post did work:\n$ osascript -e \u0026#39;tell app \u0026#34;System Events\u0026#34; to key code 36\u0026#39; # Enter key lands in TextEdit, types a newline So the daemon has some synthesis capability. Bare keys go through. Modifier-bearing keys destined for Carbon hotkey listeners do not. The daemon is granted Accessibility. AXIsProcessTrustedWithOptions(NULL) (the \u0026ldquo;is my process allowed to use the Accessibility APIs\u0026rdquo; check) returns false anyway, which I assumed was the problem. (See the TCC cdhash trap post for why TCC grants can be present in the UI but invisible to APIs.)\nIt wasn\u0026rsquo;t. AX trust and event-post privilege are different TCC services. Quinn at Apple DTS (Developer Technical Support, Apple\u0026rsquo;s official engineer-staffed forum where \u0026ldquo;Quinn\u0026rdquo; is a recognised name engineers cite by) spells this out on the dev forums: kTCCServiceAccessibility, kTCCServicePostEvent, and kTCCServiceListenEvent are three independent buckets, three separate rows in TCC.db, each tracked independently. The Accessibility toggle flips all three for keyboard-poster apps, but the APIs query them separately. CGEventPost was working: my events were reaching WindowServer (the macOS process that owns the screen, all windows, and the keyboard/mouse event pipeline; analogous to Xorg on Linux). They were being dropped before the hotkey matcher.\nThe gate I\u0026rsquo;d been ignoring # Three days in, I went looking for the actual filter. The answer is in WindowServer.\nCarbon\u0026rsquo;s RegisterEventHotKey is a thin shim over a private SkyLight RPC (SkyLight is the private framework WindowServer exposes, undocumented by Apple but reverse-engineered for years; \u0026ldquo;RPC\u0026rdquo; here just means cross-process function call) that registers a per-process entry in WindowServer\u0026rsquo;s hotkey table. The match happens inside the WindowServer process, before per-app dispatch. WindowServer can distinguish events that came from the IOHIDSystem driver (real hardware, the kernel module that reads bytes off the USB keyboard) from events that came from CGEventPost. The function that draws the line is CGXSenderCanSynthesizeEvents().\nJamf\u0026rsquo;s reverse-engineering writeup quotes WindowServer\u0026rsquo;s own log strings: \u0026ldquo;Dropping mouse down event because sender\u0026rsquo;s PID (899) isn\u0026rsquo;t 0 or self (828)\u0026rdquo;. That\u0026rsquo;s the gate refusing a synthesized event because the sender PID isn\u0026rsquo;t WindowServer itself. The same gate fires for keyboard events destined for the hotkey matcher.\nI was thinking about it wrong. On older macOS, ad-hoc-signed binaries with a TCC PostEvent grant could synthesize events that reached the matcher, sometimes. I have no source confirming that\u0026rsquo;s ever been reliable. What I can say is what I observed on macOS 26.5: the bare-key post lands in the focused field, the modifier-bearing post to a Carbon hotkey listener does not, for a daemon signed only with codesign --sign -. Same daemon, same TCC grants, two different outcomes.\nThe two paths past the gate, as far as I can tell, are:\nShip through the Mac App Store and inherit a real signing identity. Inject events through a virtual HID device that registers as real hardware. Karabiner-Elements ships exactly that (Karabiner-DriverKit-VirtualHIDDevice), and the Karabiner team explicitly calls out CGEventPost as inadequate for the same reason. Neither was realistic for a hobby daemon whose code-signing budget is \u0026ldquo;what security create-certificate lets me do for free.\u0026rdquo; Time to find another way in.\nPlan C: ask the app, not the OS # Reframe: if I can\u0026rsquo;t make the OS deliver the keystroke, can I trigger Wispr Flow\u0026rsquo;s start-dictation function some other way? The hotkey is one entry point. Surely it\u0026rsquo;s not the only one.\nWispr Flow is an Electron app. Electron apps almost universally register a URL scheme through Info.plist\u0026rsquo;s CFBundleURLTypes key (Info.plist is the XML/binary metadata file inside every .app bundle at Contents/Info.plist; the CFBundleURLTypes array tells macOS \u0026ldquo;this app handles these foo:// URLs\u0026rdquo;). That\u0026rsquo;s how install flows and \u0026ldquo;open in app\u0026rdquo; buttons on websites work. If Wispr Flow\u0026rsquo;s URL handler routes to the same internal function as the hotkey, I\u0026rsquo;d have a working path with zero synthesis.\nStep one: does it have a scheme?\n$ /usr/libexec/PlistBuddy -c \u0026#34;Print :CFBundleURLTypes\u0026#34; \\ \u0026#34;/Applications/Wispr Flow.app/Contents/Info.plist\u0026#34; Array { Dict { CFBundleURLName = ai.wispr.flow.deeplink CFBundleURLSchemes = Array { wispr-flow } } } It does. wispr-flow://. But what URLs does it accept?\nStep two: grep the bundle. (Electron ships its JavaScript inside app.asar, a single archive file mounted like a tar, containing the entire Electron app\u0026rsquo;s JS source. It\u0026rsquo;s plain enough that strings lifts readable text out of it directly.)\n$ strings \u0026#34;/Applications/Wispr Flow.app/Contents/Resources/app.asar\u0026#34; \\ | grep -E \u0026#34;wispr-flow://[a-z/-]+\u0026#34; | sort -u wispr-flow://auth/transfer/success wispr-flow://billing/cancel wispr-flow://billing/success wispr-flow://open wispr-flow://start-hands-free wispr-flow://stop-hands-free wispr-flow://switch-mic start-hands-free was sitting there the whole time.\nStep three: confirm it routes through the same code path as the hotkey, not some second-class deeplink-only handler that does half the work.\n$ strings \u0026#34;/Applications/Wispr Flow.app/Contents/Resources/app.asar\u0026#34; \\ | grep -B 1 -A 4 \u0026#34;DeeplinkStartHandsFree\u0026#34; if (status === Idle || status === Dismissed) { Qw(SB.Deeplink); sendEvent(\u0026#34;DeeplinkStartHandsFree\u0026#34;, { success: true }); } else { sendEvent(\u0026#34;DeeplinkStartHandsFree\u0026#34;, { success: false, reason: \u0026#34;not_idle\u0026#34; }); } Qw(SB.Deeplink) is the same state-machine call as the hotkey path. SB.Deeplink is just an enum tag for analytics. The handler even emits a DeeplinkStartHandsFree event with a success: false, reason: \u0026quot;not_idle\u0026quot; branch, which means it\u0026rsquo;s a real, tested code path, not a vestigial scaffold.\nThe fix # open -g wispr-flow://start-hands-free In the daemon, that\u0026rsquo;s a single spawn (daemon/src/wisprFlowTrigger.ts):\nspawn(\u0026#34;open\u0026#34;, [\u0026#34;-g\u0026#34;, url], { detached: true, stdio: [\u0026#34;ignore\u0026#34;, \u0026#34;pipe\u0026#34;, \u0026#34;pipe\u0026#34;], }).unref(); The -g flag is load-bearing. Without it, open activates Wispr Flow and steals focus from your editor, so when you finish dictating, the transcribed text lands in Wispr Flow\u0026rsquo;s own window instead of where you were typing. With -g, LaunchServices (the macOS subsystem that knows which app handles which file type or URL scheme, the thing that fires when you double-click a .pdf or click a slack:// link) delivers the URL without bringing the app forward.\nNo CGEventPost. No bun:ffi. No fight with CGXSenderCanSynthesizeEvents. No TCC PostEvent grant needed for the trigger. The daemon doesn\u0026rsquo;t even need Accessibility for this path. It only needs permission to spawn open, which every process has.\nSide notes that took time to verify # A few caveats that aren\u0026rsquo;t load-bearing for the fix but were worth checking:\nCold-start latency. If Wispr Flow isn\u0026rsquo;t running, the first open launches it; roughly 1-2 seconds in my testing on this M-series machine. Subsequent presses dispatch instantly. Adding Wispr Flow to Login Items skips the cold start entirely.\nStopping dictation. wispr-flow://stop-hands-free exists and works the same way. In practice Wispr Flow auto-stops on silence, so I haven\u0026rsquo;t wired stop to the Stream Deck yet.\nWispr Flow\u0026rsquo;s own focus call. I traced the deeplink dispatcher further and found it calls hubWindow.focus() unconditionally at the entry point, before routing to the start-hands-free handler. If Wispr Flow\u0026rsquo;s hub window happens to be visible, that focus call can still steal focus even with open -g. The mitigation is a 200-400 ms timer that restores the original window\u0026rsquo;s focus after firing the URL. I haven\u0026rsquo;t shipped that yet because the hub window is hidden in my workflow, so the hubWindow.focus() is a no-op visually. If the focus steal becomes a problem, the research doc has the full restore pattern, lifted from Hammerspoon and BetterTouchTool.\nWhy this path is structurally better # Hotkey synthesis, when it works, traverses something like:\ndaemon → CGEventPost → WindowServer → CGXSenderCanSynthesizeEvents → Carbon hotkey table match → Wispr Flow.startHandsFree() Four kernel-side gates. Two of them care about whether you\u0026rsquo;re signed by Apple.\nThe deeplink path:\ndaemon → open → LaunchServices → Wispr Flow.appDelegate.application(_:open:) → Wispr Flow.startHandsFree() Two gates. Neither one cares about signing. Same end state.\nThe synthesis path was the workaround. The deeplink is the direct call.\nLessons # A few principles that would have saved me three days, in order of how often I\u0026rsquo;ll need them again:\nWhen the OS won\u0026rsquo;t deliver your event, the app probably exposes what you want via a URL scheme. Check Info.plist\u0026rsquo;s CFBundleURLTypes first. For Electron apps, strings app.asar | grep '://' finds every registered scheme in 30 seconds. AXIsProcessTrusted returning false is not always the problem. TCC has separate buckets for Accessibility, PostEvent, and ListenEvent. Use CGPreflightPostEventAccess if all you do is post events. WindowServer can tell synthesized events from real ones. No public flag on CGEventRef changes that. If you need to pass for real hardware, you need a virtual HID device, not a smarter CGEventPost invocation. Carbon RegisterEventHotKey matches inside WindowServer, before per-app dispatch. Synthesizing events for a foreground app to consume is a different problem from synthesizing events for a Carbon hotkey listener to consume. Plan A and Plan B were the same dead end; I just didn\u0026rsquo;t know it. If you\u0026rsquo;re going to decompile an Electron app to find an undocumented entry point, do it in the first hour, not the fourth day. Hotkey synthesis is a 2010 technique. On Tahoe, for unsigned binaries, it\u0026rsquo;s deprecated in practice if not on paper. If you\u0026rsquo;re hitting it for a daemon you\u0026rsquo;re not going to ship through the App Store, jump straight to the URL-scheme search.\nRelated # The cdhash trap that broke my Accessibility grant every rebuild: same daemon, different TCC pitfall The Claude Code hooks docs are wrong. Here\u0026rsquo;s what\u0026rsquo;s actually on the wire.: what the daemon does once it\u0026rsquo;s running Holding HTTP open for 590s to gate a tool call: the daemon\u0026rsquo;s permission flow References # Aditya Vaidyam, Building a Better RegisterEventHotKey (2018-03-16): reverse-engineering of the SkyLight RPC behind Carbon hotkeys. Jamf, Synthetic Reality: WindowServer log strings showing the CGXSenderCanSynthesizeEvents filter rejecting synthesized events by PID. Tencent KeenLab, WindowServer: The privilege chameleon on macOS Pt.1 (2016-07-22): internal IPC structure of WindowServer, including the synthesizer-privilege gate. Apple Developer Forums #744440: Quinn on CGEventTap vs AXIsProcessTrusted, with the recommendation to use CGPreflight{Post,Listen}EventAccess. Apple, CGRequestPostEventAccess: the PostEvent TCC gate. Karabiner-Elements DEVELOPMENT.md: explicit rationale for not using CGEventPost, and the virtual HID driver that bypasses the filter. Hammerspoon issue #313: independent confirmation that hs.eventtap.keyStroke cannot trigger hs.hotkey listeners, with the same WindowServer root cause. ClaudeDeck deeplink trigger implementation (Bucket A): daemon/src/wisprFlowTrigger.ts. ClaudeDeck full research notes for this post (Bucket A): docs/2026-05-09-wispr-flow-hotkey-research.md. The wispr-flow://start-hands-free URL was not found in any public Wispr Flow documentation. I discovered it by running /usr/libexec/PlistBuddy -c \u0026quot;Print :CFBundleURLTypes\u0026quot; against Wispr Flow.app/Contents/Info.plist and then strings Contents/Resources/app.asar | grep 'wispr-flow://'. Reproducible on any installed copy. ","date":"10 May 2026","externalUrl":null,"permalink":"/posts/tahoe-hotkey-dead-end/","section":"Posts","summary":" I press the Stream Deck key. The daemon logs the press, synthesizes `Cmd+Opt+;` through CoreGraphics, and exits cleanly. Wispr Flow does nothing. Three Apple subsystems and one decompiled Electron bundle later, the working trigger turned out to be a one-line URL. Building ClaudeDeck · Part 9 of 10 1 2 3 4 5 6 7 8 9 10 The plan was the boring kind: Stream Deck key (the physical button on Elgato’s programmable USB grid) → WebSocket message → my daemon (long-running background process) → synthesized global hotkey → Wispr Flow’s hands-free dictation starts (Wispr Flow is the voice-to-text Mac app that types your speech into the focused window) → I talk → words show up in my editor. I’d done variants of this with osascript (macOS’s command-line AppleScript runner) years ago. Should have taken an afternoon.\n","title":"What replaced CGEventPost in my Stream Deck daemon","type":"posts"},{"content":" Obsidian + Claude Code is everywhere right now. But pointing an AI at a folder of markdown files and hoping for the best doesn't work. What matters is how you structure the knowledge base. Get that right, and Claude becomes genuinely useful. Get it wrong, and you get confident garbage. There\u0026rsquo;s been a wave of posts about this combo lately: James Bedford\u0026rsquo;s full walkthrough, Greg Isenberg\u0026rsquo;s \u0026ldquo;personal OS\u0026rdquo; approach, kepano (Obsidian\u0026rsquo;s CEO) sharing Claude Skills. They\u0026rsquo;re all worth reading.\nWhat most of these guides cover is the setup. Install Claude Code, point it at your vault, go. This post is about the structure that makes the whole thing actually work.\nWhy Structure Matters More Than Tools # You can run cd ~/my-vault \u0026amp;\u0026amp; claude right now. Claude will read your files, answer questions about them, and even create new notes. The problem is quality.\nIf your vault is a flat folder of 200 files with inconsistent naming, no metadata, and no conventions, Claude will produce notes that match: inconsistent, hard to find, disconnected from everything else.\nThe AI inherits your organizational discipline. Or your lack of it.\nJames Bedford made an interesting observation in his walkthrough. He initially put everything in Obsidian, including content Claude generated, but found it diluted his knowledge graph. He now separates notes he personally wrote from AI-generated content.\nI went the opposite direction. Claude writes directly into my vault, alongside my own notes, using the same templates and conventions. The key difference: I invested upfront in structure that constrains Claude\u0026rsquo;s output to match what I\u0026rsquo;d write myself.\nMy Setup at a Glance # I use Obsidian as my primary knowledge base for study notes and technical reference material. The vault has 170+ markdown notes organized into modules and topics, version-controlled with git, and synced to GitHub. Claude Code has 9 custom skills for creating and maintaining content.\nThe structure follows a simple hierarchy:\nVault/ ├── .claude/skills/ # 9 Claude Code skills ├── CLAUDE.md # AI assistant rulebook ├── Topic-Area/ │ ├── Module-01/ │ │ ├── 00-Overview.md │ │ ├── 01-First-Topic.md │ │ ├── 02-Second-Topic.md │ │ └── 03-Third-Topic.md │ ├── Module-02/ │ │ └── ... │ └── Module-03/ │ └── ... ├── Dashboards/ └── Templates/ Nothing fancy. But every part of it is deliberate, and that deliberateness is what makes Claude effective.\nThe Principles That Make It Work # Consistent Naming and Numbering # Every file follows the same pattern: kebab-case English name, number prefix for ordering.\nModule-03/ ├── 00-Overview.md ├── 01-Introduction.md ├── 02-Core-Concepts.md ├── 03-Capabilities.md ├── 04-Advanced-Topics.md ├── 05-Training-Methods.md ├── 06-Optimization.md ├── 07-Activation-Functions.md ├── 08-Practical-Applications.md └── 09-Common-Pitfalls.md When I tell Claude to create a new note in a module, it can look at existing files and figure out the next number automatically. No ambiguity, no conflicts. The naming pattern is predictable enough that Claude doesn\u0026rsquo;t need to be told where to put things.\nRich Frontmatter as a Contract # Every note includes YAML frontmatter with metadata:\n--- title: \u0026#34;Topic Title\u0026#34; module: Module-03 tags: - core-concept - intermediate content_type: concept difficulty: beginner has_practice_questions: true has_diagrams: true estimated_reading_time: \u0026#34;15 min\u0026#34; status: complete created: 2025-12-15 updated: 2026-01-20 --- This isn\u0026rsquo;t just metadata for Obsidian\u0026rsquo;s Dataview plugin. It\u0026rsquo;s a contract between me and Claude about what each note is. When Claude creates a new note, it fills in all these fields because the pattern is documented in CLAUDE.md. When Claude searches the vault, it can filter by difficulty, content type, or status without reading every file.\nThink of frontmatter as a schema. The more consistent your schema, the more reliably AI can work with your data.\nTemplates That Enforce Structure # I have two templates. An overview template for entry points and a note template for individual topics:\n# Topic Title \u0026gt; One-line summary of this note. --- ## Learning Objectives - Objective 1 - Objective 2 --- ## Main Content Your content here... --- ## Related Links - Previous: [[Previous-Note]] - Next: [[Next-Note]] --- ## Practice Questions \u0026gt; Question here? \u0026gt; [!success]- Click for answer \u0026gt; Answer here. The template does two things. It gives me consistent notes that are easy to navigate. And it gives Claude a pattern to follow without explicit instruction. After seeing 50 notes in this format, Claude will produce new notes that look the same. Templates are free structure.\nCLAUDE.md as the Rulebook # The vault has its own CLAUDE.md file at the root. It covers:\nThe full directory structure File naming conventions (English, kebab-case, number prefixes) Editing guidelines (preserve wiki links, use LaTeX for math, support tags) How to add new modules (step-by-step) A note template for reference Which Obsidian plugins are in use A table of all 9 Claude skills and when to use each If you do one thing from this post, write a CLAUDE.md for your vault. It doesn\u0026rsquo;t need to be long. Document your folder structure, your naming conventions, and your note template. That alone will transform how Claude interacts with your knowledge base. Skills for Repeatable Workflows # Claude Code supports custom skills: markdown files in .claude/skills/ that define reusable workflows. I have 9 of them. A few examples:\nConvert Transcript takes raw lecture or reference material, identifies main topics, splits them into separate notes, adds examples and practice questions, and links everything together with wiki links. One transcript becomes a full module with 8-10 interconnected notes.\nVault Health Check audits the vault for broken internal links, missing tags, inconsistent naming, orphan files, and empty folders. I run it before pushing to GitHub. It catches the kind of drift that makes a vault gradually less useful over time.\nSearch and Organize helps me find notes across modules and reorganize them when the structure evolves. Useful when I realize a concept belongs in a different module than where I originally put it.\nThe idea is the same as Boris Cherny\u0026rsquo;s slash commands for Claude Code (covered in my previous post): anything you do more than twice should be automated.\nWhat Claude Code Can Do in a Vault # Here\u0026rsquo;s the concrete workflow. A typical session looks like:\nI paste raw material (a transcript, an article, or rough notes) into Claude Code The \u0026ldquo;Convert Transcript\u0026rdquo; skill breaks it into structured notes with frontmatter, examples, and practice questions I review and edit. Claude handles the scaffolding, I handle the understanding The \u0026ldquo;Health Check\u0026rdquo; skill verifies all links resolve and all notes have proper metadata The \u0026ldquo;Git Push\u0026rdquo; skill commits and pushes to GitHub Other things Claude handles regularly:\nGenerating study materials from existing notes (summaries, practice sets) Reorganizing notes when I restructure a module Filling in missing metadata across batches of notes Creating overview files that link to all notes in a module What Doesn\u0026rsquo;t Work Yet # Honest assessment of the limitations:\nObsidian\u0026rsquo;s graph view is invisible to Claude. Claude reads files and follows wiki links. But it doesn\u0026rsquo;t understand the emergent relationships that Obsidian\u0026rsquo;s graph view reveals. It can\u0026rsquo;t say \u0026ldquo;these two modules are highly connected\u0026rdquo; unless you tell it.\nLarge vaults can exceed context windows. With 170 notes, Claude can\u0026rsquo;t hold the entire vault in context at once. It works file-by-file or module-by-module. For vault-wide operations, you need to be specific about scope.\nCanvas files are awkward. They\u0026rsquo;re JSON under the hood, and Claude can technically edit them, but reasoning about spatial layout in a canvas isn\u0026rsquo;t something Claude does well.\nPlugin-specific features need hand-holding. Bases queries, Dataview syntax, Templater scripts. Claude knows these exist, but you need to be explicit about what you want. The CLAUDE.md helps, but it\u0026rsquo;s not automatic.\nGetting Started # If you want to try this:\nOpen your terminal in your Obsidian vault folder and run claude Create a CLAUDE.md at the vault root. Describe your folder structure, naming conventions, and note format Pick your most repeated task and make it a skill. For most people, that\u0026rsquo;s creating new notes from raw material Add frontmatter to your notes. Even basic fields (title, tags, date) give Claude something to work with Check out kepano\u0026rsquo;s Claude Skills for Obsidian for inspiration on what\u0026rsquo;s possible You don\u0026rsquo;t need to restructure your entire vault. Start with one folder, get the conventions right, and expand from there.\nThe Real Payoff # Here\u0026rsquo;s what I didn\u0026rsquo;t expect: the biggest benefit isn\u0026rsquo;t the AI. It\u0026rsquo;s that building for AI forced me to build a better knowledge base.\nConsistent naming, rich metadata, clear templates, documented conventions. These make the vault better for me, not just for Claude. I can find notes faster, navigate modules more intuitively, and trust that new content will fit the existing structure.\nClaude is the forcing function. Good structure is the actual reward.\nReferences: James Bedford\u0026rsquo;s Obsidian + Claude walkthrough, kepano\u0026rsquo;s Claude Skills for Obsidian, Greg Isenberg\u0026rsquo;s personal OS approach\n","date":"21 March 2026","externalUrl":null,"permalink":"/posts/obsidian-claude-code-knowledge-base/","section":"Posts","summary":" Obsidian + Claude Code is everywhere right now. But pointing an AI at a folder of markdown files and hoping for the best doesn't work. What matters is how you structure the knowledge base. Get that right, and Claude becomes genuinely useful. Get it wrong, and you get confident garbage. There’s been a wave of posts about this combo lately: James Bedford’s full walkthrough, Greg Isenberg’s “personal OS” approach, kepano (Obsidian’s CEO) sharing Claude Skills. They’re all worth reading.\n","title":"Building a Knowledge Base That AI Can Actually Use","type":"posts"},{"content":"","date":"21 March 2026","externalUrl":null,"permalink":"/tags/knowledge-management/","section":"Tags","summary":"","title":"Knowledge-Management","type":"tags"},{"content":"","date":"21 March 2026","externalUrl":null,"permalink":"/tags/obsidian/","section":"Tags","summary":"","title":"Obsidian","type":"tags"},{"content":" Boris Cherny created Claude Code. When he shared how he actually uses it day-to-day, the setup was surprisingly simple. I went through every tip, tried most of them, and have opinions about all of them. The original thread is on Boris\u0026rsquo;s X account. A good companion site is howborisusesclaudecode.com which compiles everything in one place.\nThe \u0026ldquo;Surprisingly Vanilla\u0026rdquo; Setup # Boris opens with this: his setup is surprisingly vanilla. Claude Code works great out of the box, and he doesn\u0026rsquo;t customize it much.\nThis is worth sitting with for a second. The person who built the tool doesn\u0026rsquo;t have some secret 500-line CLAUDE.md or a custom plugin stack. He basically uses the defaults.\nI think there\u0026rsquo;s a lesson here. The developer tooling community has a habit of over-engineering setups before actually using the thing. I\u0026rsquo;ve seen people spend more time configuring their AI coding assistant than actually coding with it. Boris\u0026rsquo;s approach is the opposite: start simple, add customization only when you hit a real friction point.\nThat said, \u0026ldquo;vanilla\u0026rdquo; for the creator of Claude Code and \u0026ldquo;vanilla\u0026rdquo; for the rest of us are probably different things. He knows the tool\u0026rsquo;s internals. He knows what it can do without being told. The rest of us might need a bit more scaffolding.\nOpus for Everything # Boris uses Opus 4.5 with extended thinking for every single task. His reasoning: even though Opus is bigger and slower than Sonnet, you steer it less. It\u0026rsquo;s better at tool use, better at following complex instructions, and that makes it faster end-to-end.\nI\u0026rsquo;ve gone back and forth on this. For a while I was using Sonnet for quick tasks (rename this variable, write a test for this function) and Opus for anything architectural. Boris\u0026rsquo;s argument changed how I think about it.\nThe cost isn\u0026rsquo;t just \u0026ldquo;time to generate output.\u0026rdquo; It\u0026rsquo;s also the correction loops. When a smaller model misunderstands the task and you spend three turns fixing it, that Sonnet speed advantage evaporates.\nMy take: Opus for anything that touches more than one file. For single-file, well-scoped changes where I know exactly what I want, Sonnet is still fine. But I\u0026rsquo;ve moved my default to Opus. 5 Parallel Sessions # This is the one that blew people\u0026rsquo;s minds. Boris runs 5 Claude Code instances in parallel across 5 separate git checkouts of the same repo, numbered tabs 1-5. On top of that, he runs 5-10 more sessions on claude.ai/code. He uses system notifications to know when Claude needs input.\nThe result: 20-30 PRs per day.\nThat number is impressive, but context matters. He\u0026rsquo;s working on the Claude Code codebase itself, which he knows intimately. He can scope tasks precisely because he wrote most of the code. Each parallel session gets a well-defined, independent task.\nFor my own work, I\u0026rsquo;ve tried running 3 sessions in parallel. It works best when:\nThe tasks are genuinely independent (different features, different parts of the codebase) Each task is well-scoped enough that Claude can run for a while without needing input You have a good mental model of what each session is doing Where it falls apart: when tasks overlap, when you need to context-switch constantly to answer Claude\u0026rsquo;s questions, or when you don\u0026rsquo;t have enough independent work to fill the sessions. Running 5 instances on a small project is just waste.\nCLAUDE.md as Team Knowledge # The Claude Code team shares a single CLAUDE.md checked into git. The whole team contributes to it multiple times a week. Whenever Claude does something wrong, they add a note so it doesn\u0026rsquo;t happen again.\nThis is the tip I find most valuable.\nI\u0026rsquo;ve been using CLAUDE.md as a personal config file: project structure, build commands, testing conventions. Boris treats it as a living document that captures institutional knowledge. Every mistake becomes a permanent fix.\nThink about it: in a normal team, someone discovers a gotcha, mentions it in Slack, and it\u0026rsquo;s forgotten in two days. With CLAUDE.md, that gotcha becomes a rule that every team member\u0026rsquo;s AI assistant follows forever.\n# Example CLAUDE.md pattern (inspired by Boris\u0026#39;s approach) ## Things Claude gets wrong in this codebase - Don\u0026#39;t use `console.log` for debugging, use the `logger` module - The `user` table has soft deletes — always filter by `deleted_at IS NULL` - Tests must not hit the network — use the `nock` fixtures in `test/fixtures/` If I could only keep one tip from the entire thread, it would be this one. A team-maintained CLAUDE.md is more valuable than any individual configuration trick.\nPlan Mode First # Most of Boris\u0026rsquo;s sessions start in Plan mode (Shift+Tab twice). If the goal is a PR, he iterates on the plan until he likes it, then switches to auto-accept edits mode. Claude usually one-shots it from there.\nI\u0026rsquo;ve adopted this pattern and it works well. The planning step catches misunderstandings before Claude writes 200 lines of code in the wrong direction. Without Plan mode, I\u0026rsquo;d often get a large diff that was 80% right but required manual cleanup. With Plan mode, the success rate on the first attempt is noticeably higher.\nThe workflow looks like:\nEnter Plan mode (Shift+Tab twice) Describe what you want Read Claude\u0026rsquo;s plan, push back on parts you disagree with Once the plan looks right, switch to Act mode Let Claude execute Step 3 is where most of the value is. It\u0026rsquo;s cheaper to fix a plan than to fix code.\nSlash Commands for Inner Loops # Boris uses custom slash commands for every workflow he repeats multiple times a day. These are markdown files in .claude/commands/ that define reusable prompts.\nI haven\u0026rsquo;t used these as much as I should. Most of my repetitive tasks are things like \u0026ldquo;run the tests and fix whatever fails\u0026rdquo; or \u0026ldquo;lint this file and fix the issues.\u0026rdquo; Those are good candidates for slash commands.\n# .claude/commands/fix-tests.md Run the test suite. For any failing tests, analyze the failure, fix the code (not the test unless the test itself is wrong), and re-run until all tests pass. The idea is simple: anything you type more than twice should be a slash command. Boris has these for his inner-loop workflows. I\u0026rsquo;m starting to build mine.\nPermissions, Not \u0026ndash;dangerously-skip # Boris doesn\u0026rsquo;t use --dangerously-skip-permissions. Instead, he uses /permissions to pre-allow specific bash commands he knows are safe in his environment. Most of these are checked into .claude/settings.json and shared with the team.\nThis is the security-conscious approach. --dangerously-skip-permissions is a sledgehammer. /permissions is a scalpel.\nIn practice, I\u0026rsquo;ve found a handful of permissions covers 90% of the prompts:\nnpm test, npm run lint git commands (status, diff, log) hugo server, hugo build npx playwright test Pre-allowing these means Claude can run tests and lint without asking, while still prompting for anything destructive. Much better than either extreme (constant prompts or no safety at all).\nMCP Integrations # Claude Code uses all of Boris\u0026rsquo;s tools for him. It searches and posts to Slack (via MCP server), runs BigQuery queries, grabs error logs from Sentry. The Slack MCP configuration is checked into .mcp.json and shared with the team.\nMCP (Model Context Protocol) is where Claude Code goes from \u0026ldquo;AI code editor\u0026rdquo; to \u0026ldquo;AI coworker.\u0026rdquo; When Claude can pull context from Slack, query your database, and check your error logs, the quality of its suggestions improves dramatically because it has the same context you do.\nI don\u0026rsquo;t have Slack or Sentry hooked up, but I use MCP for documentation lookups. The principle is the same: the more context Claude has about your environment, the less you have to explain.\nVerification is Everything # Boris saved the most important tip for last: give Claude a way to verify its work. If Claude has a feedback loop, it will 2-3x the quality of the final result.\nHis example: Claude tests every single change he lands to claude.ai/code using the Claude Chrome extension. It opens a browser, tests the UI, and iterates until the code works and the UX feels good.\nFor those of us without a Chrome extension testing setup, the same principle applies at a simpler level:\nWrite tests before asking Claude to implement the feature Include \u0026ldquo;run the tests and verify\u0026rdquo; as the last step of every prompt Use CI as a verification step (Claude can read CI output and fix issues) This is the tip that made the biggest difference in my workflow. Before, I\u0026rsquo;d review Claude\u0026rsquo;s output and manually test. Now, I make sure Claude can test its own work. The quality jump is real. Not 2-3x for every task, but for anything UI-related or integration-heavy, it\u0026rsquo;s significant. What I\u0026rsquo;m Taking Away # If I had to compress Boris\u0026rsquo;s thread into three principles:\nStart simple, customize when friction appears. Don\u0026rsquo;t over-engineer your setup. Make CLAUDE.md a team habit. Every mistake Claude makes should become a permanent rule. Give Claude a feedback loop. Tests, linters, browser automation. The tool that can verify its own work produces dramatically better results. The parallel sessions and Opus-for-everything tips are worth experimenting with, but they\u0026rsquo;re optimizations on top of these fundamentals. Get the basics right first.\nWhat\u0026rsquo;s your Claude Code setup? I\u0026rsquo;m always curious how other developers are using it.\nSources: Boris Cherny\u0026rsquo;s original thread, howborisusesclaudecode.com\n","date":"21 March 2026","externalUrl":null,"permalink":"/posts/boris-claude-code-tips/","section":"Posts","summary":" Boris Cherny created Claude Code. When he shared how he actually uses it day-to-day, the setup was surprisingly simple. I went through every tip, tried most of them, and have opinions about all of them. The original thread is on Boris’s X account. A good companion site is howborisusesclaudecode.com which compiles everything in one place.\n","title":"What I Learned from How Claude Code's Creator Uses Claude Code","type":"posts"},{"content":"","date":"22 February 2026","externalUrl":null,"permalink":"/tags/automation/","section":"Tags","summary":"","title":"Automation","type":"tags"},{"content":"","date":"22 February 2026","externalUrl":null,"permalink":"/tags/aws/","section":"Tags","summary":"","title":"Aws","type":"tags"},{"content":"","date":"22 February 2026","externalUrl":null,"permalink":"/tags/billing/","section":"Tags","summary":"","title":"Billing","type":"tags"},{"content":" I spent five years on the AWS Billing team. The hardest problem I tackled was detecting when customers used AWS services but weren't charged correctly. This post walks through how I designed a system that reduced charge discrepancies by **300x** and eliminated **230 million** monthly false positives. The Problem # AWS billing is trickier than it looks. When a customer launches an EC2 instance, writes to S3, or queries DynamoDB, each action generates a usage record. These records flow through a pipeline that calculates charges based on the customer\u0026rsquo;s pricing plan, region, and service tier.\nSometimes, usage records don\u0026rsquo;t make it through the pipeline correctly. A record might get dropped, delayed, or processed with the wrong pricing. The customer gets under-charged (AWS loses revenue) or over-charged (customer trust erodes). At AWS\u0026rsquo;s scale, even a tiny error rate across millions of customers adds up fast.\nThe existing detection system flagged potential discrepancies, but it generated 230 million false positive alerts per month. That volume made the alerts useless in practice. Engineers would triage a few, find nothing, and ignore the rest. Real discrepancies were buried in noise.\nThe Challenge # The core tension was precision vs. recall:\nToo aggressive: Flag everything, drown in false positives (the status quo) Too conservative: Miss real discrepancies, lose money and customer trust Just right: Flag only genuine issues, with enough context to act quickly Additionally, the system had to:\nProcess billions of usage records across every AWS service Handle the different pricing models, discount programs, and billing cycles Run continuously without impacting the billing pipeline\u0026rsquo;s latency Produce actionable alerts, not just \u0026ldquo;something looks wrong\u0026rdquo; The Approach # I designed the Unbilled Usage Auditor as a distributed system with three components:\nflowchart LR A[\"Smart Sampling\\n\u0026 Aggregation\"] --\u003e B[\"Multi-Signal\\nValidation\"] B --\u003e C[\"Automated\\nResolution\"] C --\u003e D{Real issue?} D -- Yes --\u003e E[\"Alert with\\ndiagnosis\"] D -- No --\u003e F[\"Auto-resolve\"] 1. Smart Sampling and Aggregation # Instead of checking every individual usage record (which generated the 230M false positives), I aggregated usage at the service-account-period level. This reduced the comparison space by orders of magnitude while preserving the ability to detect genuine discrepancies.\nThe aggregation was built on DynamoDB for its consistent low-latency reads at any scale. Each aggregated record stored the expected charge (from usage records) and the actual charge (from the billing output), along with metadata about the pricing plan and discount programs applied.\n2. Multi-Signal Validation # A single mismatch between expected and actual charges doesn\u0026rsquo;t necessarily indicate a problem. Pricing changes, retroactive discounts, and billing cycle boundaries all create legitimate temporary discrepancies.\nI built a validation pipeline using AWS Lambda that checked multiple signals before escalating:\nTemporal correlation: Is this a timing issue that will self-correct in the next billing cycle? Pricing context: Did a pricing change or discount activation explain the difference? Historical pattern: Has this account/service combination shown similar patterns before? Magnitude thresholds: Is the discrepancy large enough to warrant investigation? Only records that failed all validation checks were escalated as genuine alerts.\n3. Automated Resolution Pipeline # For common discrepancy patterns, the system could trigger automated remediation: re-processing usage records, applying missing discounts, or flagging records for manual review with specific context about what went wrong.\nSo when an engineer did receive an alert, it came with a diagnosis, not just a symptom.\nThe Results # After rolling out the Unbilled Usage Auditor:\nMetric Before After Change Monthly false positives 230,000,000 \u0026lt; 1,000 -99.99% Charge discrepancies $125,000 $432 -99.65% (300x) Time to resolve alerts Days Hours ~10x faster Alert actionability ~0% (noise) ~95% (genuine) Usable The 300x reduction in charge discrepancies (from $125,000 to $432) came from catching real issues that were previously buried under false positives. The system didn\u0026rsquo;t just reduce noise. It uncovered signal that had always been there.\nWhat I Learned # Correctness Is a Spectrum # In billing systems, \u0026ldquo;correct\u0026rdquo; isn\u0026rsquo;t binary. There\u0026rsquo;s the charge that\u0026rsquo;s mathematically right given current pricing, the charge that\u0026rsquo;s right given the customer\u0026rsquo;s expectation, and the charge that\u0026rsquo;s right given the business rules (discounts, credits, negotiations). The system had to reason about all three.\nFalse Positive Reduction Is Its Own Feature # Reducing false positives wasn\u0026rsquo;t just an optimization. It was a prerequisite for the system being useful at all. A detection system that cries wolf 230 million times a month is a noise generator, not a detection system. The biggest impact came not from finding new problems, but from making existing problems visible.\nScale Changes the Problem # At AWS scale, approaches that work for thousands of records completely break at billions. The shift from per-record checking to aggregated analysis wasn\u0026rsquo;t just an optimization; it required rethinking the problem from scratch. When your scale changes by 1000x, your architecture probably needs to change, not just your hardware.\nThe hardest part of this project wasn\u0026rsquo;t the distributed systems engineering. It was understanding the domain deeply enough to tell a real discrepancy from expected behavior. Technical skill got me the system; domain knowledge got me the 300x improvement. This project shaped how I think about system design: start with correctness guarantees, then optimize for signal-to-noise ratio, and always build systems that explain their decisions.\nFor more about my career journey and other projects, see my experience page or projects page.\n","date":"22 February 2026","externalUrl":null,"permalink":"/posts/case-study-aws-billing-auditor/","section":"Posts","summary":" I spent five years on the AWS Billing team. The hardest problem I tackled was detecting when customers used AWS services but weren't charged correctly. This post walks through how I designed a system that reduced charge discrepancies by **300x** and eliminated **230 million** monthly false positives. The Problem # AWS billing is trickier than it looks. When a customer launches an EC2 instance, writes to S3, or queries DynamoDB, each action generates a usage record. These records flow through a pipeline that calculates charges based on the customer’s pricing plan, region, and service tier.\n","title":"Case Study: Building AWS Billing's Unbilled Usage Auditor","type":"posts"},{"content":" At Twitter, I was responsible for kernel updates across **5,000+ production servers**. Updating a kernel is risky on one machine. Doing it across a fleet, without downtime, without data loss, and without breaking the services that millions of people depend on, is a different problem entirely. The Problem # Twitter\u0026rsquo;s production infrastructure ran on thousands of bare-metal servers across multiple data centers. Each server ran a Linux kernel that needed regular updates for security patches, performance improvements, and hardware compatibility.\nThe challenge wasn\u0026rsquo;t updating one kernel. It was updating thousands, safely:\nHeterogeneous fleet: Different hardware generations, different workloads, different kernel configurations. A kernel that works perfectly on one host type might crash on another. Zero tolerance for downtime: These servers ran core Twitter services. A bad kernel update could take down a shard of the user timeline, DM delivery, or ad serving. Manual process: Before my work, kernel updates were largely manual. Engineers would update hosts in small batches, watch for issues, and roll back if something went wrong. At 5,000+ hosts, this didn\u0026rsquo;t scale. Validation gap: There was no systematic way to validate that a new kernel version was safe for a given host type before rolling it out. The Approach # I built three interlocking systems to solve this:\nflowchart LR A[\"Canary\\nValidation\"] --\u003e B[\"Wave 1\\n1%\"] B --\u003e C[\"Wave 2\\n5%\"] C --\u003e D[\"Wave 3\\n25%\"] D --\u003e E[\"Full Fleet\"] B -- anomaly --\u003e F[\"Pause \u0026\\nAlert\"] C -- anomaly --\u003e F D -- anomaly --\u003e F 1. Canary Kernel Validation Library # Before any kernel could be rolled out fleet-wide, it had to pass canary validation. I built a Python library that:\nProvisioned canary hosts: Selected representative hosts from each hardware/workload combination in the fleet Applied the kernel update: Installed the new kernel and rebooted the canary hosts Ran validation suites: Checked system stability, performance benchmarks, and application-level health checks Compared baselines: Measured the canary against production baselines: CPU utilization, memory pressure, I/O latency, network throughput Only after a kernel passed canary validation on every host type would it be approved for fleet-wide rollout.\nCanary validation isn\u0026rsquo;t just \u0026ldquo;does the kernel boot?\u0026rdquo; It\u0026rsquo;s \u0026ldquo;does the kernel behave identically to the current one under production-like load?\u0026rdquo; A kernel can boot fine and still introduce a 5% latency regression that cascades into user-visible impact. 2. Automated Rollout System # Once a kernel was validated, the rollout system handled deployment in progressive waves:\nWave 1: 1% of the fleet (a handful of hosts per data center) Wave 2: 5%, expanding to more host types Wave 3: 25%, majority coverage Wave 4: Remaining hosts Between each wave, the system monitored for anomalies: unexpected reboots, performance regression, application errors. If any signal crossed a threshold, the rollout paused automatically and alerted the on-call engineer with context about what went wrong and which hosts were affected.\n3. Fleet Configuration Standardization # I discovered that a big chunk of fleet management pain came from configuration drift. Hosts had been manually tweaked over years and no longer matched their expected state.\nI built tooling to:\nAudit configurations: Scan every host and compare its actual state to the declared state Detect drift: Identify hosts that had diverged from their intended configuration Remediate automatically: For safe divergences, apply corrections. For risky ones, flag for human review. This wasn\u0026rsquo;t strictly a kernel problem, but it was a prerequisite. You can\u0026rsquo;t safely automate kernel updates on hosts whose configuration you don\u0026rsquo;t fully understand.\n4. Cache Service Custom Commands # I also designed a custom commands system for Twitter\u0026rsquo;s Redis -based cache services using Go . This let operators inspect and modify cache behavior at runtime without restarting services, which was critical for debugging production issues without customer impact.\nThe Results # Metric Before After Kernel update method Manual, batch-by-batch Automated, progressive rollout Time to update fleet Weeks Days Hosts with validated kernels Partial 5,000+ (full fleet) Configuration drift detection None Continuous On-call ticket volume (peak) Unmanageable 140+ resolved in one week The \u0026ldquo;140+ tickets in one on-call week\u0026rdquo; stat deserves context. This wasn\u0026rsquo;t a normal week; the fleet had accumulated significant technical debt. The tooling I\u0026rsquo;d built let me systematically triage and resolve issues that would have previously required investigating each host individually.\nWhat I Learned # Automation Without Validation Is Dangerous # The temptation with fleet automation is to focus on speed: how fast can we push updates to every host? But speed without validation means speed at failing. The canary validation system was the most important piece, not the rollout automation.\nConfiguration Drift Is the Silent Killer # The hardest bugs to debug in fleet management aren\u0026rsquo;t kernel bugs. They\u0026rsquo;re \u0026ldquo;why does this host behave differently from every other host of the same type?\u0026rdquo; The answer is almost always configuration drift that accumulated over months or years. Investing in configuration auditing paid for itself many times over.\nOn-Call Is a Design Problem # Resolving 140+ tickets in a week wasn\u0026rsquo;t about working harder. It was about having the right tools. When your tooling gives you enough context to diagnose and resolve issues in minutes instead of hours, you can handle 10x the volume. The best on-call experience is one where the tools do the investigation and the human makes the decision.\nFleet management at scale comes down to trust: trusting that your hosts are in the state you think they are, trusting that an update won\u0026rsquo;t break things, and trusting that if something does go wrong, you\u0026rsquo;ll know immediately and can recover automatically. Every system I built was about establishing and maintaining that trust. This experience shaped my approach to infrastructure: instrument everything, validate before acting, and design systems that explain themselves when they fail.\nFor more about my career journey and other projects, see my experience page or projects page.\n","date":"22 February 2026","externalUrl":null,"permalink":"/posts/case-study-twitter-fleet-automation/","section":"Posts","summary":" At Twitter, I was responsible for kernel updates across **5,000+ production servers**. Updating a kernel is risky on one machine. Doing it across a fleet, without downtime, without data loss, and without breaking the services that millions of people depend on, is a different problem entirely. The Problem # Twitter’s production infrastructure ran on thousands of bare-metal servers across multiple data centers. Each server ran a Linux kernel that needed regular updates for security patches, performance improvements, and hardware compatibility.\n","title":"Case Study: Fleet-Scale Kernel Automation at Twitter","type":"posts"},{"content":"","date":"22 February 2026","externalUrl":null,"permalink":"/tags/case-study/","section":"Tags","summary":"","title":"Case-Study","type":"tags"},{"content":"","date":"22 February 2026","externalUrl":null,"permalink":"/tags/catppuccin/","section":"Tags","summary":"","title":"Catppuccin","type":"tags"},{"content":" Your development environment should feel like **one cohesive tool**, not a collection of unrelated windows with clashing colors. I theme everything with the same palette: Catppuccin Mocha . The result is a workspace where context-switching between tools is effortless. Why One Palette Everywhere? # Most developers pick a theme for their editor and call it a day. Their terminal is one color, their editor another, their tmux status bar a third, and their Git diffs something else entirely. Every time they switch contexts, their brain spends a fraction of a second recalibrating.\nThat friction adds up. When red means error in every single tool (terminal output, editor diagnostics, Git diffs, monitoring dashboards), you process information faster because your visual vocabulary is consistent.\nThe Palette # Catppuccin is a community-driven pastel theme with four flavor variants. I use Mocha, the darkest flavor, across everything.\nBase Colors # Base, Surface0, Surface1: the foundation. Deep blues that are easy on the eyes during long sessions.\nAccent Colors # Blue (info/links), Red (errors/deletions), Green (success/additions): these carry semantic meaning across every tool.\nWarm Accents # Peach (warnings), Yellow (highlights), Mauve (keywords): used for syntax highlighting and UI accents.\nWhere I Use It # Every tool in my daily workflow runs Catppuccin Mocha:\nConfiguration Snippets # Here\u0026rsquo;s how I set up Catppuccin Mocha in each tool:\n# ~/.config/ghostty/config theme = catppuccin-mocha background-opacity = 0.75 background-blur-radius = 20 Ghostty ships with Catppuccin built-in. Just set the theme name. The transparency lets the palette blend with the desktop for a frosted-glass aesthetic.\n-- LazyVim extra or manual plugin { \u0026#34;catppuccin/nvim\u0026#34;, name = \u0026#34;catppuccin\u0026#34;, opts = { flavour = \u0026#34;mocha\u0026#34;, transparent_background = true, integrations = { treesitter = true, telescope = { enabled = true }, which_key = true, }, }, } The Catppuccin Neovim plugin has first-class integrations with virtually every popular plugin: Treesitter, Telescope, nvim-cmp, and more all get coordinated colors.\n# ~/.config/tmux/tmux.conf set -g @plugin \u0026#39;catppuccin/tmux\u0026#39; set -g @catppuccin_flavor \u0026#39;mocha\u0026#39; set -g @catppuccin_window_status_style \u0026#34;rounded\u0026#34; The tmux plugin adds a themed status bar with window indicators, session name, and system info, all matching the terminal and editor.\n# ~/.zshrc export FZF_DEFAULT_OPTS=\u0026#34; \\ --color=bg+:#313244,bg:#1e1e2e,spinner:#f5e0dc \\ --color=hl:#f38ba8,fg:#cdd6f4,header:#f38ba8 \\ --color=info:#cba6f7,pointer:#f5e0dc,marker:#f5e0dc \\ --color=fg+:#cdd6f4,prompt:#cba6f7,hl+:#f38ba8\u0026#34; FZF\u0026rsquo;s color configuration takes raw hex values, so you set each UI element individually. Once configured, fuzzy finding matches the rest of your environment perfectly.\n# ~/.gitconfig [delta] syntax-theme = Catppuccin Mocha minus-style = syntax \u0026#34;#3B1219\u0026#34; plus-style = syntax \u0026#34;#1C3A2D\u0026#34; Delta (the Git pager) uses the Catppuccin bat theme for syntax highlighting in diffs. The custom minus/plus styles tint additions green and deletions red using the Mocha palette.\nThe Cognitive Load Argument # The real benefit isn\u0026rsquo;t aesthetics: it\u0026rsquo;s that context-switching between terminal, editor, and browser no longer requires your brain to re-adapt to different color semantics. Red means error everywhere. Blue means info everywhere. Green means success everywhere. When I jump from reading a Git diff (red = deleted, green = added) to my editor (red = error diagnostic, green = test passing) to my terminal output (red = failed command, green = success), my brain doesn\u0026rsquo;t skip a beat. The color semantics are identical.\nThis is the same principle behind why airports, hospitals, and road signs use standardized color systems. Consistency reduces cognitive overhead.\nThe Full Color Reference # For anyone wanting to replicate this setup, here are the key Catppuccin Mocha values I use most:\nRole Color Hex Usage Base Dark blue #1e1e2e Backgrounds Text Light lavender #cdd6f4 Primary text Blue Soft blue #89b4fa Links, info, variables Red Soft red #f38ba8 Errors, deletions Green Soft green #a6e3a1 Success, additions Peach Warm peach #fab387 Warnings, numbers Yellow Soft yellow #f9e2af Highlights, strings Mauve Purple #cba6f7 Keywords, headings Overlay Muted gray #6c7086 Comments, secondary text Getting Started # If you want to try Catppuccin Mocha yourself, the project has ports for 300+ apps. Start with your terminal emulator and editor, then expand from there.\nFor my complete configuration files including all the snippets above, check out my dotfiles:\n","date":"22 February 2026","externalUrl":null,"permalink":"/posts/catppuccin-mocha-theming/","section":"Posts","summary":" Your development environment should feel like **one cohesive tool**, not a collection of unrelated windows with clashing colors. I theme everything with the same palette: Catppuccin Mocha . The result is a workspace where context-switching between tools is effortless. Why One Palette Everywhere? # Most developers pick a theme for their editor and call it a day. Their terminal is one color, their editor another, their tmux status bar a third, and their Git diffs something else entirely. Every time they switch contexts, their brain spends a fraction of a second recalibrating.\n","title":"Catppuccin Mocha: Why I Theme Everything the Same Color","type":"posts"},{"content":"","date":"22 February 2026","externalUrl":null,"permalink":"/tags/distributed-systems/","section":"Tags","summary":"","title":"Distributed-Systems","type":"tags"},{"content":"","date":"22 February 2026","externalUrl":null,"permalink":"/tags/infrastructure/","section":"Tags","summary":"","title":"Infrastructure","type":"tags"},{"content":"","date":"22 February 2026","externalUrl":null,"permalink":"/tags/theming/","section":"Tags","summary":"","title":"Theming","type":"tags"},{"content":" Imagine you're **blindfolded on a mountain** and you need to find the lowest valley. You can't see anything, but you *can* feel the ground under your feet. What would you do? You'd feel which direction slopes downward, take a small step that way, and repeat. Congratulations. You just invented **gradient descent**, the algorithm behind nearly every modern AI system. ML Fundamentals · Part 1 of 3 1 2 3 Why Should You Care? # Optimization is everywhere. When your GPS finds the fastest route, when Netflix recommends a movie, when your phone recognizes your face, behind all of these is an algorithm trying to find the best possible answer from a sea of possibilities. Gradient descent is the workhorse algorithm that makes this happen.\nThe Blindfolded Hiker # Let\u0026rsquo;s flesh out the analogy with a mapping table:\nHiking (Real World) Gradient Descent (Math) Your position on the mountain Current weights \\(\\mathbf{w}\\) Your altitude Error \\(E\\) The slope of the ground under your feet Gradient \\(\\nabla E\\) Your step size Learning rate \\(\\eta\\) The valley floor Optimal solution The hiker\u0026rsquo;s algorithm is simple:\nFeel the slope under your feet (compute the gradient) Step downhill in the steepest direction (update the weights) Repeat until the ground feels flat (convergence) Why Do We Need This? # Here\u0026rsquo;s the problem machines face. Say you\u0026rsquo;re building a model to predict whether a student will pass an exam based on how many hours they studied. Your model has adjustable knobs called weights, and you need to find the weight values that make the best predictions.\nBut you can\u0026rsquo;t just try every possible value. With even a few weights, the number of combinations is astronomical. Instead, you start somewhere and iteratively improve, just like our blindfolded hiker.\nThe Error Function: How Wrong Are We? # First, we need a way to measure \u0026ldquo;how wrong\u0026rdquo; our model is. The most common choice is squared error:\n$$E(\\mathbf{w}) = \\frac{1}{2}\\sum_{(x,y) \\in D}(y - a)^2$$\nSymbol Meaning \\(y\\) The correct answer (target) \\(a\\) Our model\u0026rsquo;s prediction (activation) \\(\\frac{1}{2}\\) A convenience factor that makes the derivative cleaner This function creates an error surface: imagine a landscape where altitude represents how wrong you are. Every point on this landscape corresponds to a different set of weights. Our goal: find the lowest point.\nThe Gradient: Which Way Is Downhill? # The gradient is a vector that points in the direction where the error increases the fastest:\n$$\\nabla E = \\left[\\frac{\\partial E}{\\partial w_0}, \\frac{\\partial E}{\\partial w_1}, \\ldots, \\frac{\\partial E}{\\partial w_n}\\right]$$\nSince we want to decrease error, we walk in the opposite direction of the gradient:\n$$\\mathbf{w} \\leftarrow \\mathbf{w} - \\eta \\nabla E$$\nThe minus sign is doing the heavy lifting here: it flips \u0026ldquo;uphill\u0026rdquo; into \u0026ldquo;downhill.\u0026rdquo;\nCommon confusion: The gradient tells you the slope, not a destination. It says \u0026ldquo;error increases fastest in this direction,\u0026rdquo; not \u0026ldquo;the minimum is over there.\u0026rdquo; That\u0026rsquo;s why we take small steps rather than leaping. The Delta Rule: A Concrete Formula # For a single-neuron model predicting with \\(a = \\sum_i w_i x_i\\), we can derive a clean update rule using the chain rule:\n$$\\frac{\\partial E}{\\partial w_i} = -(y - a) \\cdot x_i$$\nSince we move against the gradient:\n$$\\Delta w_i = \\eta(y - a)x_i$$\nThis is called the Delta Rule, and it\u0026rsquo;s beautifully intuitive:\n\\((y - a)\\): How wrong are we? If the prediction is too low, this is positive → increase the weight \\(x_i\\): How much did this input contribute? Bigger inputs get bigger adjustments \\(\\eta\\): How big a step should we take? (the learning rate) A Step-by-Step Example # Problem: Predict if a student passes an exam based on hours studied.\nInput: \\(x = [1, 2]\\) (1 is the bias term, 2 hours studied) Target: \\(y = 1\\) (passed) Initial weights: \\(w = [0.1, 0.3]\\) Learning rate: \\(\\eta = 0.1\\) Iteration 1 # Predict: \\(a = 0.1 \\times 1 + 0.3 \\times 2 = 0.7\\)\nError: \\(y - a = 1 - 0.7 = 0.3\\) (we predicted too low)\nUpdate: $$\\Delta w_0 = 0.1 \\times 0.3 \\times 1 = 0.03$$ $$\\Delta w_1 = 0.1 \\times 0.3 \\times 2 = 0.06$$\nNew weights: \\(w = [0.13, 0.36]\\)\nWatch It Converge # Iteration \\(w_0\\) \\(w_1\\) Prediction \\(a\\) Error 0 0.100 0.300 0.700 0.300 1 0.130 0.360 0.850 0.150 2 0.145 0.390 0.925 0.075 3 0.153 0.405 0.963 0.037 \u0026hellip; \u0026hellip; \u0026hellip; \u0026hellip; → 0 Notice how the error shrinks each iteration, and the updates get smaller too. When you\u0026rsquo;re close to the answer, you take smaller steps. The algorithm naturally slows down as it approaches the solution.\nBatch vs. Stochastic: Two Flavors of Descent # There are two main ways to apply gradient descent, and a restaurant analogy helps explain the difference:\nBatch Gradient Descent # Batch GD looks at all the training data before making a single update.\nAnalogy: You\u0026rsquo;re a chef. You ask every single customer what they thought of the meal, compile all the feedback, then make one careful adjustment to the recipe.\nPro: Stable, smooth path toward the minimum Con: Slow. You have to process everything before each step Stochastic Gradient Descent (SGD) # SGD updates after each individual training example.\nAnalogy: You ask one customer what they thought and immediately tweak the recipe. Then the next customer, tweak again. It\u0026rsquo;s chaotic but fast.\nPro: Much faster, and the randomness can help escape bad solutions Con: Noisy, zigzag path graph LR subgraph Batch[\"Batch GD\"] B1[\"Smooth, directpath to minimum\"] --\u003e B2[\"●Minimum\"] end subgraph SGD[\"Stochastic GD\"] S1[\"Zigzag, noisypath to minimum\"] --\u003e S2[\"●Minimum\"] end In practice, most systems use mini-batch gradient descent, a middle ground where you look at a small batch (say 32 or 64 examples) at a time. It gets the best of both worlds.\nThe Local Optima Trap # Here\u0026rsquo;s the catch: gradient descent finds a local minimum, not necessarily the global minimum.\nThink about it. Our blindfolded hiker can only feel the ground directly underfoot. If they walk into a small ditch, the ground slopes up in every direction, so they stop. But there might be a much deeper valley a mile away that they\u0026rsquo;ll never find.\ngraph LR A[\"Start\"] --\u003e B[\"Walk downhill...\"] B --\u003e C[\"Stuck inlocal minimum!\"] C -.-\u003e|\"Can't see this\"| D[\"Global minimum(the real answer)\"] Solutions to Getting Stuck # Strategy How It Works Random restarts Try hiking from multiple random starting points and keep the best result Momentum Keep some \u0026ldquo;inertia\u0026rdquo; so you can roll through small ditches Simulated annealing Occasionally allow uphill steps early on, then settle down over time The good news: in modern deep learning with millions of parameters, the error landscape is so high-dimensional that true local minima are actually quite rare. Most \u0026ldquo;valleys\u0026rdquo; have an escape route in some dimension.\nWhy This Powers Everything # Gradient descent isn\u0026rsquo;t just a classroom algorithm: it\u0026rsquo;s literally how every modern neural network learns:\nChatGPT learned to write by gradient descent over billions of text examples Self-driving cars use it to tune perception models on driving data Drug discovery uses it to optimize molecular property predictions Your phone\u0026rsquo;s keyboard prediction was trained with it Every time you hear \u0026ldquo;the model was trained on data,\u0026rdquo; gradient descent (or a close variant like Adam) is doing the actual training under the hood.\nKey Takeaways # Gradient descent finds good solutions by repeatedly taking small steps in the direction that reduces error The gradient tells you which direction is \u0026ldquo;uphill\u0026rdquo;, so you go the opposite way The learning rate controls step size: too big and you overshoot, too small and you\u0026rsquo;ll take forever Batch gradient descent is stable but slow; stochastic is fast but noisy You might get stuck in a local minimum, but there are tricks to escape Study Notes: This post is based on my personal notes from studying Machine Learning (CS7641) at Georgia Institute of Technology. The concepts are explained in my own words for learning purposes. ","date":"20 February 2026","externalUrl":null,"permalink":"/posts/ml-gradient-descent/","section":"Posts","summary":" Imagine you're **blindfolded on a mountain** and you need to find the lowest valley. You can't see anything, but you *can* feel the ground under your feet. What would you do? You'd feel which direction slopes downward, take a small step that way, and repeat. Congratulations. You just invented **gradient descent**, the algorithm behind nearly every modern AI system. ML Fundamentals · Part 1 of 3 1 2 3 Why Should You Care? # Optimization is everywhere. When your GPS finds the fastest route, when Netflix recommends a movie, when your phone recognizes your face, behind all of these is an algorithm trying to find the best possible answer from a sea of possibilities. Gradient descent is the workhorse algorithm that makes this happen.\n","title":"Finding the Bottom of a Valley Blindfolded: Understanding Gradient Descent","type":"posts"},{"content":"","date":"20 February 2026","externalUrl":null,"permalink":"/tags/georgia-tech/","section":"Tags","summary":"","title":"Georgia-Tech","type":"tags"},{"content":" Imagine you're playing **20 Questions**. You're trying to guess what animal your friend is thinking of. Would you start with \"Is it a golden retriever?\" or \"Does it live in water?\" The second question is obviously smarter, because it eliminates roughly half the possibilities in one shot. Decision trees in machine learning work exactly the same way, and they use **entropy** and **information gain** to figure out what the smartest question is. ML Fundamentals · Part 2 of 3 1 2 3 What\u0026rsquo;s the Big Idea? # When a machine learning algorithm builds a Decision Tree , it needs to decide which question to ask first. Should it split the data by color? By size? By temperature? The answer comes from a beautifully simple concept: ask the question that reduces uncertainty the most.\nBut to measure \u0026ldquo;uncertainty,\u0026rdquo; we need a number for it. That number is called entropy.\nEntropy: Measuring Chaos # The Messy Room Analogy # Think of entropy as a messiness score:\nSituation Entropy Why All your clothes are folded in the closet Low You know exactly where everything is Clothes are scattered everywhere High You have no idea where anything is In machine learning, \u0026ldquo;messiness\u0026rdquo; means how mixed up the categories are in your data.\nThe Marble Bag # Imagine reaching into a bag of marbles without looking:\nBag A: All red marbles → Entropy = 0 (you know what you\u0026rsquo;ll grab) Bag B: Half red, half blue → Entropy = 1 (maximum uncertainty, it\u0026rsquo;s a coin flip) Bag C: All blue marbles → Entropy = 0 (certain again) The more mixed up the bag is, the higher the entropy. When everything is the same, entropy is zero. There\u0026rsquo;s no surprise.\nThe Math (It\u0026rsquo;s Simpler Than It Looks) # For a dataset \\(S\\) with multiple classes, entropy is:\n$$Entropy(S) = -\\sum_{i=1}^{c} p_i \\log_2(p_i)$$\nWhere \\(p_i\\) is the proportion of class \\(i\\) in the dataset, and \\(c\\) is the number of classes.\nFor the simple case of two classes (yes/no, spam/not-spam):\n$$Entropy(S) = -p_+ \\log_2(p_+) - p_- \\log_2(p_-)$$\nLet\u0026rsquo;s plug in numbers for the marble bags:\nBag A (all red): \\(Entropy = -1 \\times \\log_2(1) - 0 \\times \\log_2(0) = 0\\) Bag B (half and half): \\(Entropy = -0.5 \\times \\log_2(0.5) - 0.5 \\times \\log_2(0.5) = 1\\) The entropy curve looks like a hill: it peaks at 0.5 (maximum confusion) and drops to zero at the extremes (total certainty).\nWhy log base 2? Because entropy is measured in bits, the same bits as in computer science. An entropy of 1 bit means you need exactly one yes/no question to figure out the answer. This connects directly to the \u0026ldquo;20 Questions\u0026rdquo; game! Information Gain: Picking the Best Question # Now for the punchline. Information gain measures how much a particular question reduces entropy:\n$$Gain(S, A) = Entropy(S) - \\sum_{v \\in Values(A)} \\frac{|S_v|}{|S|} \\times Entropy(S_v)$$\nIn plain English: Information Gain = Entropy before asking − Entropy after asking\nA bigger information gain means a better question.\nWorked Example: Should I Play Tennis? # Let\u0026rsquo;s say you have 8 days of data about whether you played tennis:\n5 days you played (✅) 3 days you didn\u0026rsquo;t (❌) Starting entropy:\n$$Entropy = -\\frac{5}{8}\\log_2\\frac{5}{8} - \\frac{3}{8}\\log_2\\frac{3}{8} \\approx 0.954$$\nNow let\u0026rsquo;s compare two possible questions:\nQuestion 1: \u0026ldquo;What\u0026rsquo;s the weather?\u0026rdquo; # graph TD A[\"All Data5✅ 3❌Entropy = 0.954\"] --\u003e|Sunny| B[\"2✅ 1❌Entropy = 0.918\"] A --\u003e|Overcast| C[\"2✅ 0❌Entropy = 0\"] A --\u003e|Rainy| D[\"1✅ 2❌Entropy = 0.918\"] Weighted entropy after splitting by weather:\n$$\\frac{3}{8} \\times 0.918 + \\frac{2}{8} \\times 0 + \\frac{3}{8} \\times 0.918 = 0.689$$\nInformation Gain = 0.954 − 0.689 = 0.265\nQuestion 2: \u0026ldquo;Is it windy?\u0026rdquo; # graph TD A[\"All Data5✅ 3❌Entropy = 0.954\"] --\u003e|Light Wind| B[\"4✅ 1❌Entropy = 0.722\"] A --\u003e|Strong Wind| C[\"1✅ 2❌Entropy = 0.918\"] Weighted entropy after splitting by wind:\n$$\\frac{5}{8} \\times 0.722 + \\frac{3}{8} \\times 0.918 = 0.795$$\nInformation Gain = 0.954 − 0.795 = 0.159\nThe Verdict # Attribute Information Gain Winner? Weather 0.265 ✅ Pick this one! Wind 0.159 Weather gives more information gain, so the decision tree puts it at the top. It\u0026rsquo;s the smarter first question, just like in 20 Questions.\nWhat a Good Split Looks Like # graph LR A[\"Mixed DataHigh Entropy\"] --\u003e|Good Split| B[\"Mostly ✅Low Entropy\"] A --\u003e|Good Split| C[\"Mostly ❌Low Entropy\"] A good split takes a messy group and separates it into purer groups. A bad split leaves you with groups that are still mixed up. You haven\u0026rsquo;t learned much.\nReal-World Applications # This isn\u0026rsquo;t just a classroom exercise. Decision trees powered by entropy and information gain are used everywhere:\nSpam filtering: \u0026ldquo;Does the email contain the word \u0026rsquo;lottery\u0026rsquo;?\u0026rdquo; splits your inbox into much purer groups than \u0026ldquo;Was it sent on a Tuesday?\u0026rdquo; Medical diagnosis: A doctor\u0026rsquo;s diagnostic flowchart is essentially a decision tree: \u0026ldquo;Does the patient have a fever?\u0026rdquo; is a high-information-gain question for many conditions Recommendation systems: Streaming services split users into groups based on features that best predict what they\u0026rsquo;ll watch next Credit scoring: Banks use decision trees to determine which factors best separate reliable borrowers from risky ones Key Takeaways # Entropy measures how mixed up (uncertain) a dataset is, from 0 (pure) to 1 (maximum chaos for two classes) Information gain tells you how much a question reduces that chaos Decision trees are greedy: they always pick the question with the highest information gain first This is exactly the strategy of a smart 20 Questions player: ask the question that eliminates the most possibilities Study Notes: This post is based on my personal notes from studying Machine Learning (CS7641) at Georgia Institute of Technology. The concepts are explained in my own words for learning purposes. ","date":"20 February 2026","externalUrl":null,"permalink":"/posts/ml-entropy-and-information-gain/","section":"Posts","summary":" Imagine you're playing **20 Questions**. You're trying to guess what animal your friend is thinking of. Would you start with \"Is it a golden retriever?\" or \"Does it live in water?\" The second question is obviously smarter, because it eliminates roughly half the possibilities in one shot. Decision trees in machine learning work exactly the same way, and they use **entropy** and **information gain** to figure out what the smartest question is. ML Fundamentals · Part 2 of 3 1 2 3 What’s the Big Idea? # When a machine learning algorithm builds a Decision Tree , it needs to decide which question to ask first. Should it split the data by color? By size? By temperature? The answer comes from a beautifully simple concept: ask the question that reduces uncertainty the most.\n","title":"How Machines Ask Smart Questions: Entropy \u0026 Information Gain","type":"posts"},{"content":" When a factory produces a defective product, how do you trace the problem back through the assembly line to find which worker made the mistake? Neural networks face the exact same challenge. They have layers of \"workers\" (neurons), and when the final output is wrong, they need to figure out **who's responsible** and by how much. The algorithm that solves this is called **backpropagation**, and it's the reason deep learning works at all. ML Fundamentals · Part 3 of 3 1 2 3 Neural Networks Are Everywhere # Neural networks are behind the phone in your pocket: face recognition, voice transcription, photo enhancement, text prediction. Self-driving cars, medical image analysis, language translation. All of it.\nNobody programs these networks to do their jobs. Instead, we show them millions of examples and let them learn from mistakes. Backpropagation is the algorithm that makes this learning possible.\nThe Factory Analogy # Imagine a three-stage factory production line:\nStage Factory Neural Network Stage 1 Worker A processes raw materials Input layer → Hidden layer (weights \\(w\\)) Stage 2 Worker B assembles the product Hidden layer → Output layer (weights \\(v\\)) Stage 3 Quality inspector checks the result Error calculation When the inspector finds a defect:\nFirst, ask Worker B (closest to the output): \u0026ldquo;What went wrong in your assembly?\u0026rdquo; → The error is directly visible Then ask Worker A: \u0026ldquo;How much of Worker B\u0026rsquo;s problem traces back to your material processing?\u0026rdquo; → Blame is distributed proportionally This is exactly how backpropagation works. It starts at the output and traces the error backward through the network, assigning \u0026ldquo;blame\u0026rdquo; to each connection along the way.\nThe Network Structure # A simple network to work with: 2 inputs, 2 hidden neurons, 1 output.\ngraph LR x1((x₁)) --\u003e|w₁₁| h1((h₁)) x1 --\u003e|w₁₂| h2((h₂)) x2((x₂)) --\u003e|w₂₁| h1 x2 --\u003e|w₂₂| h2 h1 --\u003e|v₁| y((ŷ)) h2 --\u003e|v₂| y Each arrow has a weight, a number that controls how strongly one neuron influences the next. Learning means finding the right weight values.\nStep 1: The Forward Pass (Making a Prediction) # To learn from mistakes, we need to make a mistake first. The forward pass pushes data through the network:\nHidden layer: each hidden neuron computes a weighted sum and passes it through an activation function \\(\\sigma\\) (like the sigmoid function):\n$$a_j = \\sum_i w_{ij} \\cdot x_i$$ $$h_j = \\sigma(a_j) = \\frac{1}{1 + e^{-a_j}}$$\nOutput layer: same process:\n$$b = \\sum_j v_j \\cdot h_j$$ $$\\hat{y} = \\sigma(b)$$\nError (how wrong were we?):\n$$E = \\frac{1}{2}(y - \\hat{y})^2$$\nThink of the forward pass as water flowing downstream: input → hidden → output → error. Simple, one-directional.\nStep 2: The Backward Pass (Assigning Blame) # We know the final error, but we need to figure out how to adjust every single weight in the network to reduce it. We work backward.\nOutput Layer: Direct Blame # The output layer error signal combines two things:\n$$\\delta_{out} = (y - \\hat{y}) \\cdot \\sigma\u0026rsquo;(b)$$\n\\((y - \\hat{y})\\): How wrong is the prediction? \\(\\sigma\u0026rsquo;(b)\\): How sensitive is the output neuron to changes? Think of it as: \u0026ldquo;How wrong × How adjustable = How much to change\u0026rdquo;\nThen we update the output weights:\n$$\\Delta v_j = \\eta \\cdot \\delta_{out} \\cdot h_j$$\nHidden Layer: Proportional Blame # Here\u0026rsquo;s the core insight of backpropagation. Hidden neurons don\u0026rsquo;t have their own \u0026ldquo;correct answer.\u0026rdquo; We only know the final output was wrong. So we distribute blame proportionally to each connection\u0026rsquo;s strength:\n$$\\delta_j = \\delta_{out} \\cdot v_j \\cdot \\sigma\u0026rsquo;(a_j)$$\nBreaking this down:\n\\(\\delta_{out}\\): The error signal from the output \\(v_j\\): The weight connecting hidden neuron \\(j\\) to the output (a stronger connection means more responsibility) \\(\\sigma\u0026rsquo;(a_j)\\): How sensitive is hidden neuron \\(j\\) to changes? Then we update the hidden weights:\n$$\\Delta w_{ij} = \\eta \\cdot \\delta_j \\cdot x_i$$\nThe Information Flow # graph RL E[\"Error E\"] --\u003e|\"(y-ŷ) · σ'(b)\"| dout[\"δ_out\"] dout --\u003e|\"× h_j\"| dv[\"Update v weights\"] dout --\u003e|\"× v_j · σ'(a_j)\"| dh[\"δ_hidden\"] dh --\u003e|\"× x_i\"| dw[\"Update w weights\"] Error flows backward through the network. At each layer, it gets split and scaled according to the connection strengths. The chain rule from calculus is what makes this mathematically precise.\nThe Chain Rule: Why It All Works # Backpropagation is really just the chain rule from calculus, applied cleverly. If you want to know how changing a weight \\(w\\) deep inside the network affects the final error \\(E\\), you multiply the local effects at each step:\n$$\\frac{\\partial E}{\\partial w} = \\frac{\\partial E}{\\partial \\hat{y}} \\cdot \\frac{\\partial \\hat{y}}{\\partial b} \\cdot \\frac{\\partial b}{\\partial h} \\cdot \\frac{\\partial h}{\\partial a} \\cdot \\frac{\\partial a}{\\partial w}$$\nEach factor is a local gradient: how much one thing affects the next thing in line. Multiply them all together, and you know how the distant weight affects the final error.\nIt\u0026rsquo;s like a chain of dominoes: knocking over the first one (changing \\(w\\)) causes a cascade that eventually reaches the last one (changing \\(E\\)). The chain rule tells you exactly how hard that last domino falls.\nA Concrete Example with Numbers # Let\u0026rsquo;s run through a complete forward and backward pass.\nSetup:\nInputs: \\(x_1 = 0.5\\), \\(x_2 = 0.3\\) Hidden weights: \\(w_{11} = 0.4\\), \\(w_{21} = 0.2\\), \\(w_{12} = 0.3\\), \\(w_{22} = 0.5\\) Output weights: \\(v_1 = 0.6\\), \\(v_2 = 0.4\\) Target: \\(y = 1\\), Learning rate: \\(\\eta = 0.5\\) Forward Pass # Hidden neuron 1: $$a_1 = 0.4 \\times 0.5 + 0.2 \\times 0.3 = 0.26 \\quad \\Rightarrow \\quad h_1 = \\sigma(0.26) \\approx 0.565$$\nHidden neuron 2: $$a_2 = 0.3 \\times 0.5 + 0.5 \\times 0.3 = 0.30 \\quad \\Rightarrow \\quad h_2 = \\sigma(0.30) \\approx 0.574$$\nOutput: $$b = 0.6 \\times 0.565 + 0.4 \\times 0.574 = 0.569 \\quad \\Rightarrow \\quad \\hat{y} = \\sigma(0.569) \\approx 0.638$$\nError: \\(E = \\frac{1}{2}(1 - 0.638)^2 \\approx 0.065\\)\nWe predicted 0.638 but the target is 1. Time to learn!\nBackward Pass # Output error signal: $$\\delta_{out} = (1 - 0.638) \\times 0.638 \\times (1 - 0.638) \\approx 0.083$$\nOutput weight updates: $$\\Delta v_1 = 0.5 \\times 0.083 \\times 0.565 \\approx 0.024$$ $$\\Delta v_2 = 0.5 \\times 0.083 \\times 0.574 \\approx 0.024$$\nHidden error signals (this is where blame gets distributed): $$\\delta_1 = 0.083 \\times 0.6 \\times 0.565 \\times (1 - 0.565) \\approx 0.012$$ $$\\delta_2 = 0.083 \\times 0.4 \\times 0.574 \\times (1 - 0.574) \\approx 0.008$$\nNotice that \\(\\delta_1 \u0026gt; \\delta_2\\) because \\(v_1 = 0.6 \u0026gt; v_2 = 0.4\\): the neuron with the stronger connection to the output gets more blame!\nHidden weight updates: $$\\Delta w_{11} = 0.5 \\times 0.012 \\times 0.5 \\approx 0.003$$\nThe Key Observation # Weight Update Size Distance from Output \\(v\\) (output) ~0.024 1 layer away \\(w\\) (hidden) ~0.003 2 layers away The updates are 10x smaller for the hidden layer. Every layer the error travels through shrinks it further. This foreshadows a serious problem\u0026hellip;\nThe Vanishing Gradient Problem: A Game of Telephone # Remember the game of Telephone (Chinese Whispers)? A message gets distorted as it passes through more people. The same thing happens with error signals in deep networks.\nThe sigmoid function\u0026rsquo;s derivative has a maximum value of just 0.25. Every layer the error signal passes through, it gets multiplied by a number less than 0.25. In a deep network with many layers:\n$$\\text{Gradient} \\propto \\underbrace{0.25 \\times 0.25 \\times \\cdots \\times 0.25}_{N \\text{ layers}}$$\nFor a 100-layer network: \\(0.5^{100} \\approx 7.89 \\times 10^{-31}\\)\nThat\u0026rsquo;s essentially zero. The early layers receive virtually no error signal, so they can\u0026rsquo;t learn at all. This is called the vanishing gradient problem, and it\u0026rsquo;s why deep neural networks didn\u0026rsquo;t work well for decades.\nWhy Deep Learning Finally Worked (~2012) # The vanishing gradient problem was a show-stopper until researchers found clever solutions:\n[2010] **ReLU Activation** replaces Sigmoid. Instead of squishing everything through the S-shaped sigmoid curve (max derivative 0.25), ReLU simply passes positive values through unchanged (derivative = 1). No more vanishing gradients. [2015] **Residual Connections (ResNets)** add \"skip connections.\" These create shortcuts that let the gradient flow directly to earlier layers, bypassing the shrinkage problem entirely. This enabled networks with 100+ layers. [2015] **Batch Normalization** stabilizes training. By normalizing the inputs to each layer, it prevents the gradients from getting too large or too small, keeping everything in a healthy range. These breakthroughs (along with powerful GPUs and massive datasets) are why deep learning exploded in the 2010s and now powers everything from language models to image generators.\nKey Takeaways # Backpropagation distributes blame for errors backward through a network, so every weight knows how to adjust It works by applying the chain rule, multiplying local gradients at each layer Weights with stronger connections get more blame (and bigger updates) The vanishing gradient problem caused error signals to shrink to nothing in deep networks Modern fixes like ReLU, residual connections, and batch normalization solved this, enabling the deep learning revolution Study Notes: This post is based on my personal notes from studying Machine Learning (CS7641) at Georgia Institute of Technology. The concepts are explained in my own words for learning purposes. ","date":"20 February 2026","externalUrl":null,"permalink":"/posts/ml-backpropagation/","section":"Posts","summary":" When a factory produces a defective product, how do you trace the problem back through the assembly line to find which worker made the mistake? Neural networks face the exact same challenge. They have layers of \"workers\" (neurons), and when the final output is wrong, they need to figure out **who's responsible** and by how much. The algorithm that solves this is called **backpropagation**, and it's the reason deep learning works at all. ML Fundamentals · Part 3 of 3 1 2 3 Neural Networks Are Everywhere # Neural networks are behind the phone in your pocket: face recognition, voice transcription, photo enhancement, text prediction. Self-driving cars, medical image analysis, language translation. All of it.\n","title":"How Neural Networks Learn from Mistakes: Backpropagation Explained","type":"posts"},{"content":"","date":"20 February 2026","externalUrl":null,"permalink":"/tags/machine-learning/","section":"Tags","summary":"","title":"Machine-Learning","type":"tags"},{"content":"","date":"20 February 2026","externalUrl":null,"permalink":"/series/ml-fundamentals/","section":"Series","summary":"","title":"ML Fundamentals","type":"series"},{"content":"","date":"20 February 2026","externalUrl":null,"permalink":"/categories/study-notes/","section":"Categories","summary":"","title":"Study Notes","type":"categories"},{"content":"","date":"20 February 2026","externalUrl":null,"permalink":"/tags/study-notes/","section":"Tags","summary":"","title":"Study-Notes","type":"tags"},{"content":"","date":"15 February 2026","externalUrl":null,"permalink":"/tags/ghostty/","section":"Tags","summary":"","title":"Ghostty","type":"tags"},{"content":" After years of refining my terminal workflow, I've landed on a stack I genuinely enjoy using every day: **Ghostty** as the terminal emulator, **tmux** with **sesh** for session management, and **Neovim** with **LazyVim** for editing. Everything runs on macOS (Apple Silicon) with a consistent Catppuccin Mocha theme across all tools.\nThis is how it all fits together.\nGhostty: The Terminal Emulator # I switched to Ghostty from Kitty a while back and haven\u0026rsquo;t looked back. It\u0026rsquo;s written in Zig , GPU-accelerated, and buttery smooth.\nMy key configuration choices:\n# ~/.config/ghostty/config theme = catppuccin-mocha font-family = \u0026#34;Hack Nerd Font Mono\u0026#34; font-size = 14 background-opacity = 0.75 background-blur-radius = 20 The 75% transparency with 20px blur gives a nice frosted-glass look where my desktop wallpaper bleeds through. It\u0026rsquo;s purely aesthetic, but it makes long coding sessions more pleasant.\nQuake-style Drop-down Terminal # Pro tip: This is my most-used keybinding. It slides a terminal down from the top of the screen for quick commands without leaving your current context. keybind = global:super+grave_accent=toggle_quick_terminal quick-terminal-position = top quick-terminal-screen = main quick-terminal-animation-duration = 0.1 I use it constantly for git operations, running tests, and quick file checks.\nSplit Panes # keybind = super+shift+d=new_split:right keybind = super+d=new_split:down keybind = super+shift+enter=toggle_split_zoom These mirror my tmux bindings closely, so muscle memory works regardless of whether I\u0026rsquo;m in a tmux session or not.\ntmux + sesh: Session Management # tmux is the backbone of my workflow. I use Ctrl+A as my prefix (classic screen users will relate) and sesh for smart session management.\nCore Configuration # # Prefix set -g prefix C-a unbind C-b bind C-a send-prefix # Start windows and panes at 1, not 0 set -g base-index 1 setw -g pane-base-index 1 # Mouse support set -g mouse on # True color support set -g default-terminal \u0026#34;tmux-256color\u0026#34; set -ag terminal-overrides \u0026#34;,xterm-256color:RGB\u0026#34; The sesh Session Picker # The killer feature: Press Prefix + T to get an FZF-powered session picker with instant access to all tmux sessions, zoxide directories, and config paths, all fuzzy-searchable. bind-key \u0026#34;T\u0026#34; run-shell \u0026#34;sesh connect \\\u0026#34;$( sesh list | fzf-tmux -p 55%,60% \\ --no-sort --border-label \u0026#39; sesh \u0026#39; \\ --prompt \u0026#39;\u0026gt; \u0026#39; \\ --header \u0026#39; ^a all ^t tmux ^g configs ^x zoxide ^d tmux kill ^f find\u0026#39; \\ --bind \u0026#39;tab:down,btab:up\u0026#39; \\ --bind \u0026#39;ctrl-a:change-prompt(\u0026gt; )+reload(sesh list)\u0026#39; \\ --bind \u0026#39;ctrl-t:change-prompt(\u0026gt; )+reload(sesh list -t)\u0026#39; \\ --bind \u0026#39;ctrl-g:change-prompt(\u0026gt; )+reload(sesh list -c)\u0026#39; \\ --bind \u0026#39;ctrl-x:change-prompt(\u0026gt; )+reload(sesh list -z)\u0026#39; \\ --bind \u0026#39;ctrl-f:change-prompt(\u0026gt; )+reload(fd -H -d 2 -t d -E .Trash . ~)\u0026#39; \\ --bind \u0026#39;ctrl-d:execute(tmux kill-session -t {})+change-prompt(\u0026gt; )+reload(sesh list)\u0026#39; )\\\u0026#34;\u0026#34; I can jump between projects in under a second.\nvim-tmux-navigator # For seamless navigation between tmux panes and Neovim splits, I use vim-tmux-navigator. Ctrl+h/j/k/l moves between panes regardless of whether they\u0026rsquo;re tmux panes or Neovim windows.\nSession Persistence # # tmux-resurrect + tmux-continuum set -g @resurrect-capture-pane-contents \u0026#39;on\u0026#39; set -g @continuum-restore \u0026#39;on\u0026#39; set -g @continuum-save-interval \u0026#39;15\u0026#39; Sessions survive restarts. I never lose my workspace layout.\nNeovim with LazyVim # I run Neovim via bob (a Neovim version manager) and use LazyVim as my configuration framework.\nLazyVim Extras # I have 19 extras enabled, including:\nclaudecode copilot Docker Go Java Python Rust TypeScript harpoon2 treesitter-context Key Customizations # -- Transparent background to match Ghostty { \u0026#34;catppuccin/nvim\u0026#34;, opts = { transparent_background = true, }} -- Smooth scrolling { \u0026#34;karb94/neoscroll.nvim\u0026#34;, opts = { duration_multiplier = 0.4, easing = \u0026#34;linear\u0026#34;, }} -- Global statusline vim.opt.laststatus = 3 vim.opt.scrolloff = 10 vim.opt.cmdheight = 0 Claude Code Integration # I recently added keybindings for Claude Code directly in Neovim:\n-- Toggle Claude Code terminal vim.keymap.set(\u0026#34;n\u0026#34;, \u0026#34;\u0026lt;leader\u0026gt;ac\u0026#34;, function() require(\u0026#34;snacks\u0026#34;).terminal.toggle(\u0026#34;claude\u0026#34;, { win = { style = \u0026#34;terminal\u0026#34; }}) end, { desc = \u0026#34;Claude Code Toggle\u0026#34; }) -- Send visual selection to Claude vim.keymap.set(\u0026#34;v\u0026#34;, \u0026#34;\u0026lt;leader\u0026gt;as\u0026#34;, function() -- Yanks selection and sends to Claude terminal end, { desc = \u0026#34;Claude Code Send Selection\u0026#34; }) This lets me highlight code, send it to Claude for analysis, and apply suggestions, all without leaving the editor.\nThe Full Flow # Launch Ghostty Step 1 Transparent, GPU-accelerated terminal appears with frosted-glass effect. sesh Session Picker Step 2 Press Prefix + T to fuzzy-search for a project across tmux sessions, zoxide dirs, and configs. tmux Session Loads Step 3 Saved layout restores: editor on the left, terminal on the right. Neovim Opens Step 4 LazyVim with treesitter highlighting, LSP, and AI assistance ready to go. Navigate Freely Step 5 Ctrl+h/j/k/l moves between Neovim and tmux panes seamlessly. Cmd+backtick for one-off commands. Everything shares the same Catppuccin Mocha color scheme, so the visual experience is cohesive. The transparent backgrounds mean my terminal, editor, and status bars all blend together.\nWrapping Up # This setup has evolved over many iterations. The key principles:\nConsistency: Same theme, same keybindings philosophy across all tools Speed: Every action should take less than a second Integration: Tools should talk to each other (vim-tmux-navigator, sesh + zoxide, etc.) All my configs are managed with yadm and available on GitHub:\n","date":"15 February 2026","externalUrl":null,"permalink":"/posts/my-terminal-setup-2026/","section":"Posts","summary":" After years of refining my terminal workflow, I've landed on a stack I genuinely enjoy using every day: **Ghostty** as the terminal emulator, **tmux** with **sesh** for session management, and **Neovim** with **LazyVim** for editing. Everything runs on macOS (Apple Silicon) with a consistent Catppuccin Mocha theme across all tools.\n","title":"My Terminal Setup in 2026: Ghostty, tmux, and Neovim","type":"posts"},{"content":"","date":"15 February 2026","externalUrl":null,"permalink":"/tags/neovim/","section":"Tags","summary":"","title":"Neovim","type":"tags"},{"content":"","date":"1 February 2026","externalUrl":null,"permalink":"/tags/cli/","section":"Tags","summary":"","title":"Cli","type":"tags"},{"content":" I've been gradually replacing classic Unix tools with modern alternatives, mostly written in Rust . After a year of daily use, these aren't experiments anymore. They're muscle memory. The Replacements # Classic Modern Why cat bat Syntax highlighting, line numbers, git integration ls eza Icons, git status, tree view, color-coded grep ripgrep 10x faster, respects .gitignore, smart case find fd Simpler syntax, respects .gitignore, colored output cd zoxide Learns your habits, fuzzy matching sed sd Intuitive regex syntax, no escaping nightmare du dust Visual directory size with a tree view df duf Colorful, filterable disk usage top btop Beautiful TUI with mouse support, per-core graphs ps procs Colorized, searchable, tree view history atuin Encrypted sync, full-text search, workspace filtering Setting Up Aliases # In my .zshrc, I alias the classics to their replacements so the transition is invisible:\nalias cat=\u0026#34;bat\u0026#34; alias vim=\u0026#34;nvim\u0026#34; alias vi=\u0026#34;nvim\u0026#34; alias top=\u0026#34;btop\u0026#34; alias du=\u0026#34;dust\u0026#34; alias df=\u0026#34;duf\u0026#34; alias ps=\u0026#34;procs\u0026#34; For eza, I use a Zinit plugin that configures it with sensible defaults:\nzinit light z-shell/zsh-eza # Gives me: ls, ll, la, lt all backed by eza with --git, --icons, --group-directories-first Deep Dive: The Tools That Matter Most # bat: cat with Wings # bat isn\u0026rsquo;t just cat with colors. I use it as:\nA pager for other tools (git diff, man pages) A preview engine for FZF A syntax highlighter for Atuin\u0026rsquo;s history preview # Use bat as the FZF preview export FZF_CTRL_T_OPTS=\u0026#34;--preview \u0026#39;bat -n --color=always --line-range :500 {}\u0026#39;\u0026#34; ripgrep: The Search Engine # ripgrep is absurdly fast. My .ripgreprc configures smart defaults:\n--smart-case # Case-insensitive unless you use uppercase --follow # Follow symlinks --hidden # Search hidden files --max-filesize=100M I also define custom file type groups:\n--type-add=web:*.{html,css,js,ts,jsx,tsx,vue,svelte} --type-add=config:*.{json,yaml,yml,toml,ini,conf} --type-add=shell:*.{sh,bash,zsh,fish} Then I can search only web files: rg \u0026quot;useState\u0026quot; --type web.\nPower move: rgf: ripgrep piped into FZF with a preview, opening results in Neovim. rgf() { rg --color=always --line-number --no-heading \u0026#34;$@\u0026#34; | fzf --ansi --delimiter \u0026#39;:\u0026#39; \\ --preview \u0026#39;bat --color=always {1} --highlight-line {2}\u0026#39; \\ --preview-window \u0026#39;up,60%,border-bottom,+{2}+3/3,~3\u0026#39; | awk -F: \u0026#39;{print \u0026#34;+\u0026#34;$2, $1}\u0026#39; | xargs -r nvim } zoxide: cd That Learns # After using zoxide for a few months, I can\u0026rsquo;t go back. It learns which directories you visit most:\nz work # jumps to ~/workspace (most visited match) z dot # jumps to ~/dotfiles zi # interactive mode with FZF I integrate it with FZF for even more power:\neval \u0026#34;$(zoxide init zsh --hook pwd)\u0026#34; fd: find for Humans # # find hidden .env files, ignoring node_modules find . -name \u0026#34;.env\u0026#34; -not -path \u0026#34;*/node_modules/*\u0026#34; # same thing fd .env fd respects .gitignore by default and is the engine behind my FZF file finder:\nexport FZF_DEFAULT_COMMAND=\u0026#39;fd --type f --strip-cwd-prefix --hidden --follow --exclude .git\u0026#39; FZF: The Glue That Binds Everything # FZF isn\u0026rsquo;t a replacement. It\u0026rsquo;s an amplifier. It makes every other tool interactive.\nfzf-tab: Tab Completion on Steroids # zinit light Aloxaf/fzf-tab # Preview files and directories during tab completion zstyle \u0026#39;:fzf-tab:complete:cd:*\u0026#39; fzf-preview \u0026#39;eza -1 --color=always $realpath\u0026#39; zstyle \u0026#39;:fzf-tab:complete:cat:*\u0026#39; fzf-preview \u0026#39;bat --color=always $realpath\u0026#39; Now when I type cd \u0026lt;TAB\u0026gt;, I get a fuzzy searchable list with directory previews. Same for cat, vim, and any other command.\nGit Integration # # Fuzzy branch switcher gb() { git branch --all --sort=-committerdate | fzf --preview \u0026#39;git log --oneline --graph --color=always {1}\u0026#39; | sed \u0026#39;s/remotes\\/origin\\///\u0026#39; | xargs git checkout } Catppuccin Theme for FZF # Everything gets the same color treatment:\nexport FZF_DEFAULT_OPTS=\u0026#34; \\ --color=bg+:#313244,bg:#1e1e2e,spinner:#f5e0dc,hl:#f38ba8 \\ --color=fg:#cdd6f4,header:#f38ba8,info:#cba6f7,pointer:#f5e0dc \\ --color=marker:#b4befe,fg+:#cdd6f4,prompt:#cba6f7,hl+:#f38ba8 \\ --color=selected-bg:#45475a\u0026#34; Atuin: Shell History Reimagined # Atuin replaces the basic shell history with a SQLite-backed, encrypted, cross-machine synced history.\nKey features I rely on:\n# ~/.config/atuin/config.toml enter_accept = true # Execute on Enter, not just select keymap_mode = \u0026#34;auto\u0026#34; # Respects my Zsh vi-mode workspaces = true # Filter by git repo when pressing Up filter_mode_shell_up_key_binding = \u0026#34;workspace\u0026#34; style = \u0026#34;compact\u0026#34; show_preview = true Killer feature: Workspace filtering. When I press Up in a git repo, I only see commands I ran in that repo. No more scrolling through unrelated history. The secrets filter automatically strips AWS keys, GitHub tokens, and passwords from history:\nsecrets_filter = true history_filter = [ \u0026#34;^export (AWS|GITHUB|SLACK|TOKEN|SECRET|PASSWORD|KEY)\u0026#34;, ] Starship: The Prompt # Starship gives me a fast, informative prompt with minimal configuration. My setup uses Catppuccin Mocha colors with status-bar-style background segments:\nOS icon + username Current directory (truncated to 3 levels, with custom icons for ~/Documents, ~/Developer, etc.) Git branch + status (ahead/behind/dirty indicators) Active language versions (Python, Go, Rust, Node, only shown when relevant) Command duration (only if \u0026gt; 45 seconds) Installation # All of these tools are in my Brewfile. One brew bundle and you\u0026rsquo;re set. # Modern CLI replacements brew \u0026#34;bat\u0026#34; brew \u0026#34;eza\u0026#34; brew \u0026#34;ripgrep\u0026#34; brew \u0026#34;fd\u0026#34; brew \u0026#34;fzf\u0026#34; brew \u0026#34;zoxide\u0026#34; brew \u0026#34;sd\u0026#34; brew \u0026#34;dust\u0026#34; brew \u0026#34;duf\u0026#34; brew \u0026#34;btop\u0026#34; brew \u0026#34;procs\u0026#34; brew \u0026#34;atuin\u0026#34; brew \u0026#34;starship\u0026#34; Worth the Switch? # Absolutely. The common thread across all these tools:\nBetter defaults: they do what you want without flags Respect .gitignore: no more accidentally grepping node_modules Colored output: easier to scan visually Speed: most are written in Rust and noticeably faster on large codebases The transition cost is low: alias the classics, and you\u0026rsquo;ll barely notice the switch. But you\u0026rsquo;ll definitely notice when you\u0026rsquo;re on a machine without them.\nFull configs in my dotfiles repo:\n","date":"1 February 2026","externalUrl":null,"permalink":"/posts/modern-cli-tools-replacing-unix-classics/","section":"Posts","summary":" I've been gradually replacing classic Unix tools with modern alternatives, mostly written in Rust . After a year of daily use, these aren't experiments anymore. They're muscle memory. The Replacements # Classic Modern Why cat bat Syntax highlighting, line numbers, git integration ls eza Icons, git status, tree view, color-coded grep ripgrep 10x faster, respects .gitignore, smart case find fd Simpler syntax, respects .gitignore, colored output cd zoxide Learns your habits, fuzzy matching sed sd Intuitive regex syntax, no escaping nightmare du dust Visual directory size with a tree view df duf Colorful, filterable disk usage top btop Beautiful TUI with mouse support, per-core graphs ps procs Colorized, searchable, tree view history atuin Encrypted sync, full-text search, workspace filtering Setting Up Aliases # In my .zshrc, I alias the classics to their replacements so the transition is invisible:\n","title":"Modern CLI Tools That Replaced My Unix Classics","type":"posts"},{"content":"","date":"1 February 2026","externalUrl":null,"permalink":"/tags/rust/","section":"Tags","summary":"","title":"Rust","type":"tags"},{"content":"","date":"1 February 2026","externalUrl":null,"permalink":"/tags/tools/","section":"Tags","summary":"","title":"Tools","type":"tags"},{"content":"","date":"15 January 2026","externalUrl":null,"permalink":"/tags/devops/","section":"Tags","summary":"","title":"Devops","type":"tags"},{"content":"","date":"15 January 2026","externalUrl":null,"permalink":"/tags/dotfiles/","section":"Tags","summary":"","title":"Dotfiles","type":"tags"},{"content":" Every developer eventually reaches the point where their configs become too valuable to lose. Here's how I use **yadm** to manage my macOS dotfiles with automated testing, daily maintenance, and a pre-commit workflow that keeps everything in check. For me, the turning point was spending a weekend setting up a new MacBook and realizing I couldn\u0026rsquo;t reproduce my environment reliably. That\u0026rsquo;s when I started managing my dotfiles properly.\nAfter trying bare git repos, GNU Stow, and chezmoi, I settled on yadm, and it\u0026rsquo;s been my go-to for over a year.\nWhy Yadm? # There are many dotfile managers. Here\u0026rsquo;s why yadm won:\nTool Approach My Take Bare git Raw git with $HOME as work tree Works but fragile, no extras GNU Stow Symlink farm manager Requires specific directory structure chezmoi Template-based with state management Powerful but complex, uses its own DSL yadm Thin wrapper around git Git-native, minimal learning curve, built-in extras The key insight: yadm is just git. Every git command works: yadm add, yadm commit, yadm push, yadm diff. If you know git, you know yadm. What yadm adds on top:\nAlternate files: Different configs per machine using ##hostname or ##os suffixes Encryption: Encrypt sensitive files with GPG before pushing Bootstrap: Run a setup script on first clone Native $HOME tracking: No symlinks, files live where they belong My Directory Structure # Here\u0026rsquo;s what I track:\n~ ├── .zshrc # Shell config (Zsh + Zinit + Oh-My-Zsh) ├── .tmux.conf # tmux configuration ├── .config/ │ ├── ghostty/config # Ghostty terminal │ ├── kitty/kitty.conf # Kitty terminal (backup) │ ├── nvim/ # Neovim/LazyVim config │ ├── starship.toml # Prompt │ ├── atuin/config.toml # Shell history │ ├── mise/config.toml # Version manager │ └── ripgrep/config # Ripgrep defaults ├── .local/bin/ # Custom scripts ├── .Brewfile # Homebrew packages └── .yadm/ └── hooks/pre-commit # Pre-commit validation The .gitignore is crucial. Track only what you need:\n# Ignore everything by default * # Then selectively un-ignore !.zshrc !.tmux.conf !.config/ghostty/ !.config/nvim/ !.Brewfile # ... etc Automated Daily Maintenance # Homebrew Update Auto brew update \u0026\u0026 brew upgrade \u0026\u0026 brew cleanup. Keeps all packages fresh. Zinit Plugins Auto zsh -ic 'zinit update --all'. Updates all Zsh plugins. Neovim via bob Auto bob update --all. Updates Neovim version manager and builds. LazyVim Sync Auto nvim --headless \"+Lazy! sync\" +qa. Syncs all LazyVim plugins. Cleanup Auto Removes broken symlinks in ~/.local/bin. Tracks last run date to prevent duplicates. The script:\nTracks last run date to prevent duplicate runs Catches up if the laptop was off (runs on next login) Has quick aliases: mr (run), ms (status), ml (logs) I also have a control script for managing it:\ndaily-maintenance-control.sh start # Enable auto-run daily-maintenance-control.sh stop # Disable daily-maintenance-control.sh status # Check state daily-maintenance-control.sh logs # View recent logs Pre-Commit Testing # Every yadm commit runs through a pre-commit hook:\n#!/bin/bash # .yadm/hooks/pre-commit # Run the test suite bash ~/test-dotfiles.sh if [ $? -ne 0 ]; then echo \u0026#34;Tests failed. Commit aborted.\u0026#34; exit 1 fi The test suite (test-dotfiles.sh) validates:\nShell syntax ShellCheck Markdown lint YAML lint File permissions No secrets This catches mistakes before they reach the repo. I never push broken configs.\nVersion Management with Mise # Mise (formerly rtx) manages language runtimes across my machines:\n# ~/.config/mise/config.toml [tools] node = \u0026#34;lts\u0026#34; python = \u0026#34;latest\u0026#34; go = \u0026#34;latest\u0026#34; ruby = \u0026#34;latest\u0026#34; [settings] idiomatic_version_file_enable = true # Reads .nvmrc, .python-version, etc. not_found_auto_install = true # Auto-install missing versions jobs = 4 # Parallel installations Key setting: idiomatic_version_file_enable means mise respects .nvmrc, .python-version, and .tool-versions files in project directories. When I cd into a project that needs Node 18, mise automatically activates it. Practical Tips # 1. Start Small # Don\u0026rsquo;t try to track everything at once. Start with:\nyadm add ~/.zshrc yadm add ~/.config/ghostty/config yadm commit -m \u0026#34;initial: shell and terminal config\u0026#34; Add more as you modify things.\n2. Use Branches for Experiments # yadm checkout -b experiment/new-shell-config # Try things out... yadm checkout main # Revert if it didn\u0026#39;t work 3. Bootstrap Script for New Machines # Create a bootstrap that gets a fresh machine to your preferred state:\n#!/bin/bash # ~/.config/yadm/bootstrap # Install Homebrew /bin/bash -c \u0026#34;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\u0026#34; # Install packages brew bundle --file=~/.Brewfile # Set default shell chsh -s $(which zsh) echo \u0026#34;Bootstrap complete. Restart your terminal.\u0026#34; Then on a new machine:\nyadm clone https://github.com/youruser/dotfiles.git yadm bootstrap 4. Keep Sensitive Data Out # Use .gitignore aggressively and yadm\u0026rsquo;s encryption for anything sensitive:\n# Encrypt SSH configs yadm encrypt Git credentials should go through Git Credential Manager, never in dotfiles.\n5. Document Your Setup # I keep a CLAUDE.md in my dotfiles repo. It documents the architecture, conventions, and mandatory rules. This serves as both documentation for myself and instructions for AI assistants helping me modify configs.\nThe Payoff # With this setup:\nNew machine setup: Clone + bootstrap, done in under an hour Daily updates: Automated, zero manual intervention Config changes: Tested before commit, never push broken configs Cross-machine sync: yadm pull on any machine Rollback: Full git history, revert any change The initial investment is a few hours. The ongoing cost is near zero. And the peace of mind knowing your entire development environment is versioned, tested, and reproducible? Priceless.\nCheck out my full setup:\n","date":"15 January 2026","externalUrl":null,"permalink":"/posts/managing-dotfiles-with-yadm/","section":"Posts","summary":" Every developer eventually reaches the point where their configs become too valuable to lose. Here's how I use **yadm** to manage my macOS dotfiles with automated testing, daily maintenance, and a pre-commit workflow that keeps everything in check. For me, the turning point was spending a weekend setting up a new MacBook and realizing I couldn’t reproduce my environment reliably. That’s when I started managing my dotfiles properly.\n","title":"Managing Dotfiles Like a Pro with Yadm","type":"posts"},{"content":"","date":"15 January 2026","externalUrl":null,"permalink":"/tags/yadm/","section":"Tags","summary":"","title":"Yadm","type":"tags"},{"content":" Tzu-Hua (Nick) Liu # Professional Summary # I am a Senior Software Engineer at Meta working on infrastructure for Facebook Feed Ranking. With over 8 years of experience building and scaling distributed systems at major tech companies, my expertise spans backend services, data infrastructure, cloud computing, and system optimization. I have a proven track record of delivering high-impact solutions that process billions of transactions and manage thousands of servers.\nThe Journey # My path into infrastructure engineering wasn\u0026rsquo;t a straight line. I started with network security at Trend Micro in Taipei, moved to the US for grad school, and quickly realized that the problems I loved most were about scale. How do you make systems work when \u0026ldquo;a few users\u0026rdquo; becomes \u0026ldquo;a few billion\u0026rdquo;?\nAt eBay, a data science internship showed me the power of building systems that catch what humans can\u0026rsquo;t. In that case, fraud detection models processing millions of transactions. That\u0026rsquo;s when I understood that the real leverage in engineering isn\u0026rsquo;t writing clever code; it\u0026rsquo;s building systems that compound.\nAWS was where I grew up as an engineer. Five years on the Billing team taught me what it means to build something that absolutely cannot be wrong. When your system handles the financial records for every AWS customer, \u0026ldquo;good enough\u0026rdquo; doesn\u0026rsquo;t exist. I designed the unbilled usage auditor that caught $125,000 in discrepancies and reduced them to $432, a 300x improvement. That project taught me to think in terms of correctness guarantees, not just functionality.\nTwitter was a different kind of challenge: operating at scale under pressure. Managing kernel updates across 5,000+ production servers means you can\u0026rsquo;t afford to break things, but you also can\u0026rsquo;t afford to fall behind. I built the automation and validation tooling that made this possible, and resolved 140+ support tickets in a single on-call week. That taught me as much about prioritization as it did about systems.\nAt Walmart, I shifted to data infrastructure, leading the Data Lake integration for recruiting systems. That meant connecting 10+ data sources through Spark, Kafka, and Hudi pipelines. It was my first time leading an initiative at enterprise scale from design through delivery.\nNow at Meta, I\u0026rsquo;m building the infrastructure that powers Facebook Feed Ranking, the system that decides what 2 billion people see when they open Facebook. It\u0026rsquo;s the intersection of everything I\u0026rsquo;ve worked on: high-performance C++ services, ML infrastructure, and systems that have to work at a scale most engineers never encounter.\nWhat Drives Me # I\u0026rsquo;ve worked at enough companies to know what I care about:\nThe invisible layer. I\u0026rsquo;m drawn to infrastructure, the systems nobody sees until they break. There\u0026rsquo;s something satisfying about building something that billions of people rely on without ever knowing your name. Correctness over cleverness. The AWS billing experience permanently changed how I think. I\u0026rsquo;d rather have a boring system that\u0026rsquo;s reliably correct than an elegant one that\u0026rsquo;s probably correct. Making teams faster. The best infrastructure work isn\u0026rsquo;t about building systems. It\u0026rsquo;s about removing friction for the engineers who depend on your systems. Every automation I build, every tool I create, is measured by how much it unblocks others. Areas of Expertise # Backend \u0026amp; Infrastructure # High-performance backend services in C++ and Go Large-scale distributed system design and optimization ML infrastructure and ranking systems Data Engineering # Real-time stream processing with Apache Spark and Kafka Building data lakes and ETL pipelines Managing TB-scale data operations Cloud \u0026amp; DevOps # AWS services architecture and optimization Kubernetes orchestration and containerization Infrastructure as Code with Terraform and Ansible What I\u0026rsquo;m Working On Now # Outside of my day job at Meta, I\u0026rsquo;m currently:\nPursuing my MS at Georgia Tech — taking courses in ML, deep learning, and reinforcement learning while working full-time Building this site with vibe coding — using Claude Code to explore how AI tools change the way senior engineers work Refining my terminal and dotfiles setup — an ongoing obsession documented in my terminal setup post and managed with yadm Beyond Code # When I\u0026rsquo;m not architecting systems or writing code, I enjoy:\nTinkering with my terminal setup and dotfiles — I\u0026rsquo;ve written multiple posts about my Ghostty + tmux + Neovim workflow and dotfile management with yadm Contributing to open-source projects Mentoring junior engineers — my experience as a TA teaching 180+ students at UH shaped how I think about knowledge sharing Sharing knowledge through technical blog posts Let\u0026rsquo;s Connect # I\u0026rsquo;m always interested in discussing technology, innovation, and opportunities to collaborate on challenging projects.\nEmail Me LinkedIn GitHub Latest Posts # Recent AWS Billing Unbilled Usage Auditor 1 January 0001\u0026middot;Updated: 11 July 2026\u0026middot;276 words\u0026middot;2 mins Designed and built a distributed system that detects unbilled usage across all AWS services — reducing charge discrepancies by **300x** and eliminating **230 million** monthly false positives. Key Metrics # 300x Reduction in Discrepancies $125,000 → $432 230M False Positives Eliminated ~95% Alert Actionability Architecture # flowchart LR A[\"Usage Records\\n(Billions/day)\"] --\u003e B[\"Smart Sampling\\n\u0026 Aggregation\"] B --\u003e C[\"Multi-Signal\\nValidation\"] C --\u003e D[\"Automated\\nResolution\"] D --\u003e E{Real issue?} E -- Yes --\u003e F[\"Alert with\\nDiagnosis\"] E -- No --\u003e G[\"Auto-resolve\\n\u0026 Log\"] style B fill:#6366f1,color:#fff style C fill:#6366f1,color:#fff style D fill:#6366f1,color:#fff Technical Deep Dive # Aggregation over Brute-Force # Instead of checking every individual usage record (which generated 230M false positives), the system aggregates at the service-account-period level. Twitter Fleet-Scale Kernel Automation 1 January 0001\u0026middot;Updated: 11 July 2026\u0026middot;344 words\u0026middot;2 mins Built the automation and validation tooling to manage kernel updates across **5,000+ production servers** at Twitter — with zero-downtime progressive rollouts and automated canary validation. Key Metrics # 5,000+ Production Hosts Weeks → Days Rollout Time 140+ Tickets in One On-Call Week Zero-Downtime Updates Architecture # flowchart LR A[\"New Kernel\\nVersion\"] --\u003e B[\"Canary\\nValidation\"] B --\u003e C[\"Wave 1\\n1% Fleet\"] C --\u003e D[\"Wave 2\\n5% Fleet\"] D --\u003e E[\"Wave 3\\n25% Fleet\"] E --\u003e F[\"Full Fleet\\nRollout\"] C -- anomaly --\u003e G[\"Pause \u0026\\nAuto-Alert\"] D -- anomaly --\u003e G E -- anomaly --\u003e G style B fill:#6366f1,color:#fff style C fill:#6366f1,color:#fff style D fill:#6366f1,color:#fff style E fill:#6366f1,color:#fff style F fill:#6366f1,color:#fff Technical Deep Dive # Validate Before You Roll # A Python library that validates kernel safety before fleet-wide rollout: Hooks are guarantees, skills are knowledge, subagents are other people. 15 July 2026\u0026middot;Updated: 11 July 2026\u0026middot;1056 words\u0026middot;5 mins AI \u0026amp; Productivity Claude-Code Hooks Skills Subagents My Claude Code config now holds two hooks, ten skills, and three custom subagents, and most of them started life in the wrong layer. The instruction the model followed nine times out of ten lived in a prompt until I accepted that nine out of ten is a coin I lose every day. The workflow I pasted into chats became a skill. The bulk work that was draining my priciest model's quota became a fleet of cheaper agents. Same features, different failure modes. Taming Claude Code Sessions · Part 6 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS The four layers # Claude Code has four extension points, and they answer four different questions: ","externalUrl":null,"permalink":"/about/","section":"About Me","summary":"Tzu-Hua (Nick) Liu # Professional Summary # I am a Senior Software Engineer at Meta working on infrastructure for Facebook Feed Ranking. With over 8 years of experience building and scaling distributed systems at major tech companies, my expertise spans backend services, data infrastructure, cloud computing, and system optimization. I have a proven track record of delivering high-impact solutions that process billions of transactions and manage thousands of servers.\n","title":"About Me","type":"about"},{"content":" Georgia Institute of Technology 2023 - Present Master of Science in Computer Science · Atlanta, GA Focus Areas\nComputing Systems: Operating systems, compilers, and systems architecture Machine Learning: Statistical learning, deep learning, and reinforcement learning University of Houston Aug 2013 - May 2017 PhD Candidate in Computer Science · Houston, TX Research Focus\nDistributed Computing: MapReduce optimization and parallel processing algorithms Mobile Security: Android application security analysis and vulnerability detection Big Data Systems: Scalable data processing frameworks and performance optimization Teaching Experience\nAs a Teaching Assistant, I had the privilege of educating and mentoring over 180 students across multiple core computer science courses:\nData Structures and Algorithms (28 students) Taught fundamental algorithms and complexity analysis Guided students through hands-on programming assignments Computer Architecture (78 students) Explained CPU design, memory hierarchy, and instruction sets Supervised lab sessions on assembly programming Software Design (40 students) Introduced design patterns and software architecture principles Mentored team projects using agile methodologies Introduction to Computer Science (35 students) Taught programming fundamentals in Python and Java Helped students develop problem-solving skills Research Contributions\nPublished papers on MapReduce optimization techniques Developed security analysis tools for Android applications Contributed to open-source distributed computing projects National Taiwan University Sept 2010 - June 2013 Master of Science in Computer Science · Taipei, Taiwan Specialization Areas\nDistributed Systems: Consensus algorithms, fault tolerance, and system design Computer Security: Cryptography, network security, and secure coding practices Cloud Computing: Virtualization technologies and cloud architecture Academic Achievements\nGraduate Research Assistant in the Distributed Systems Lab Thesis: \u0026ldquo;Efficient Resource Allocation in Cloud Computing Environments\u0026rdquo; GPA: 3.8/4.0 Key Coursework\nAdvanced Operating Systems Distributed Database Systems Network Security Machine Learning Parallel Computing Continuous Learning # Beyond formal education, I maintain a commitment to continuous learning through:\nOnline Courses: Coursera, Udacity, and edX certifications Technical Conferences: Regular attendance at AWS re:Invent, KubeCon, and Spark Summit Professional Development: AWS certification programs and specialized training Community Involvement: Local tech meetups and hackathons ","externalUrl":null,"permalink":"/education/","section":"Education","summary":" Georgia Institute of Technology 2023 - Present Master of Science in Computer Science · Atlanta, GA Focus Areas\nComputing Systems: Operating systems, compilers, and systems architecture Machine Learning: Statistical learning, deep learning, and reinforcement learning University of Houston Aug 2013 - May 2017 PhD Candidate in Computer Science · Houston, TX Research Focus\n","title":"Education","type":"education"},{"content":" Distributed Systems ML Infrastructure C++ Python Go Java AWS Kafka Spark Learn More About Me Read My Blog ","externalUrl":null,"permalink":"/","section":"Nick Liu","summary":" Distributed Systems ML Infrastructure C++ Python Go Java AWS Kafka Spark Learn More About Me Read My Blog ","title":"Nick Liu","type":"page"},{"content":" A mix of open source projects and key professional contributions across distributed systems, data infrastructure, and developer tooling. Open Source # Professional Highlights # FB Feed Ranking Infrastructure # C++ ML Infrastructure Ranking Systems Meta Building the infrastructure that powers Facebook\u0026rsquo;s Feed Ranking — high-performance backend services in C++ that support ML engineers in delivering better ranking models. Working at the intersection of infrastructure and product, optimizing how billions of users see their Facebook Timeline.\nAWS Billing Unbilled Usage Auditor # Java DynamoDB AWS Lambda Distributed Systems Designed and built a system that detects unbilled usage across AWS services. Reduced false positives by 230 million monthly transactions and cut charge discrepancies by 300x — from $125,000 to $432.\nView Project Details Read the Story Twitter Fleet Kernel Automation # Python Go Redis Linux Kernel Standardized hardware and software configurations for 5,000+ production hosts. Built a Python library for Canary Kernel Validation and designed automation for kernel updates at fleet scale. Resolved 140+ tickets in a single on-call week.\nView Project Details Read the Story Walmart Data Lake Integration # Apache Spark Kafka Apache Hudi Big Data Led enterprise-wide Data Lake integration for recruiting data pipelines, processing data from 10+ sources using Apache Spark, Kafka, and Apache Hudi. Architected scalable solutions for real-time data processing at enterprise scale.\nTechnical Stack # C++ Python Go Java TypeScript Rust Apache Spark Kafka Kubernetes Docker AWS Terraform Redis DynamoDB View Full Experience ","externalUrl":null,"permalink":"/projects/","section":"Projects","summary":" A mix of open source projects and key professional contributions across distributed systems, data infrastructure, and developer tooling. Open Source # Professional Highlights # FB Feed Ranking Infrastructure # C++ ML Infrastructure Ranking Systems Meta Building the infrastructure that powers Facebook’s Feed Ranking — high-performance backend services in C++ that support ML engineers in delivering better ranking models. Working at the intersection of infrastructure and product, optimizing how billions of users see their Facebook Timeline.\n","title":"Projects","type":"projects"},{"content":" Technical Skills # Programming Languages Expert Level # Python - 7+ years of production experience, used for data pipelines, automation, and ML Java - Enterprise applications, Spring framework, microservices architecture JavaScript/TypeScript - Full-stack development, React, Node.js Proficient # Go - High-performance services, gRPC implementations Scala - Apache Spark applications, functional programming SQL - Complex queries, optimization, stored procedures Bash/Shell - Automation scripts, DevOps tooling Familiar # C++ - System programming, performance-critical applications C# - .NET applications, Windows services PHP - Web applications, Laravel framework Ruby - Scripting, Rails applications Big Data \u0026amp; Analytics Data Processing # Apache Spark - Large-scale data processing, ETL pipelines Apache Kafka - Real-time streaming, event-driven architecture Apache Hudi - Data lake management, incremental processing Hadoop - HDFS, MapReduce, ecosystem tools Machine Learning # Scikit-Learn - Classical ML algorithms, model training XGBoost - Gradient boosting, fraud detection models Pandas/NumPy - Data analysis, feature engineering TensorFlow - Deep learning experiments Cloud \u0026amp; Infrastructure AWS Services # Compute: EC2, Lambda, ECS, Fargate Storage: S3, EBS, EFS Database: RDS, DynamoDB, ElastiCache Analytics: EMR, Athena, Kinesis Networking: VPC, CloudFront, Route 53 DevOps: CloudFormation, CodePipeline, CloudWatch Container \u0026amp; Orchestration # Docker - Containerization, multi-stage builds Kubernetes - Container orchestration, Helm charts Mesos/Aurora - Large-scale cluster management Infrastructure as Code # Terraform - Cloud resource provisioning Ansible - Configuration management, automation CloudFormation - AWS-specific IaC Databases SQL Databases # PostgreSQL - Advanced features, performance tuning MySQL - Replication, clustering Oracle - Enterprise features, PL/SQL NoSQL Databases # DynamoDB - Serverless, auto-scaling Redis - Caching, pub/sub, data structures MongoDB - Document store, aggregation framework Cassandra - Wide column store, distributed systems DevOps \u0026amp; Tools CI/CD # GitHub Actions - Workflow automation Jenkins - Pipeline as code GitLab CI - Integrated DevOps platform Monitoring \u0026amp; Observability # Prometheus/Grafana - Metrics and visualization ELK Stack - Log aggregation and analysis New Relic - Application performance monitoring PagerDuty - Incident management Version Control \u0026amp; Collaboration # Git - Advanced workflows, branching strategies GitHub/GitLab - Code review, project management Jira/Confluence - Agile project management Soft Skills Leadership # Technical team leadership Mentoring junior engineers Cross-functional collaboration Strategic planning and execution Communication # Technical documentation Stakeholder management Presentation skills Knowledge sharing Problem Solving # System design and architecture Performance optimization Root cause analysis Data-driven decision making Certifications \u0026amp; Training AWS Certified Solutions Architect (in progress) Continuous learning through online courses and conferences Regular participation in hackathons and tech talks ","externalUrl":null,"permalink":"/skills/","section":"Technical Skills","summary":"Technical Skills # Programming Languages Expert Level # Python - 7+ years of production experience, used for data pipelines, automation, and ML Java - Enterprise applications, Spring framework, microservices architecture JavaScript/TypeScript - Full-stack development, React, Node.js Proficient # Go - High-performance services, gRPC implementations Scala - Apache Spark applications, functional programming SQL - Complex queries, optimization, stored procedures Bash/Shell - Automation scripts, DevOps tooling Familiar # C++ - System programming, performance-critical applications C# - .NET applications, Windows services PHP - Web applications, Laravel framework Ruby - Scripting, Rails applications Big Data \u0026 Analytics Data Processing # Apache Spark - Large-scale data processing, ETL pipelines Apache Kafka - Real-time streaming, event-driven architecture Apache Hudi - Data lake management, incremental processing Hadoop - HDFS, MapReduce, ecosystem tools Machine Learning # Scikit-Learn - Classical ML algorithms, model training XGBoost - Gradient boosting, fraud detection models Pandas/NumPy - Data analysis, feature engineering TensorFlow - Deep learning experiments Cloud \u0026 Infrastructure AWS Services # Compute: EC2, Lambda, ECS, Fargate Storage: S3, EBS, EFS Database: RDS, DynamoDB, ElastiCache Analytics: EMR, Athena, Kinesis Networking: VPC, CloudFront, Route 53 DevOps: CloudFormation, CodePipeline, CloudWatch Container \u0026 Orchestration # Docker - Containerization, multi-stage builds Kubernetes - Container orchestration, Helm charts Mesos/Aurora - Large-scale cluster management Infrastructure as Code # Terraform - Cloud resource provisioning Ansible - Configuration management, automation CloudFormation - AWS-specific IaC Databases SQL Databases # PostgreSQL - Advanced features, performance tuning MySQL - Replication, clustering Oracle - Enterprise features, PL/SQL NoSQL Databases # DynamoDB - Serverless, auto-scaling Redis - Caching, pub/sub, data structures MongoDB - Document store, aggregation framework Cassandra - Wide column store, distributed systems DevOps \u0026 Tools CI/CD # GitHub Actions - Workflow automation Jenkins - Pipeline as code GitLab CI - Integrated DevOps platform Monitoring \u0026 Observability # Prometheus/Grafana - Metrics and visualization ELK Stack - Log aggregation and analysis New Relic - Application performance monitoring PagerDuty - Incident management Version Control \u0026 Collaboration # Git - Advanced workflows, branching strategies GitHub/GitLab - Code review, project management Jira/Confluence - Agile project management Soft Skills Leadership # Technical team leadership Mentoring junior engineers Cross-functional collaboration Strategic planning and execution Communication # Technical documentation Stakeholder management Presentation skills Knowledge sharing Problem Solving # System design and architecture Performance optimization Root cause analysis Data-driven decision making Certifications \u0026 Training AWS Certified Solutions Architect (in progress) Continuous learning through online courses and conferences Regular participation in hackathons and tech talks ","title":"Technical Skills","type":"skills"},{"content":" Meta Dec 2025 - Present Senior Software Engineer · Bellevue, WA Building infrastructure for Facebook Feed Ranking to support ML engineers in delivering better models Developing high-performance backend services in C++ that manage how users see their Facebook Timeline Working at the intersection of infrastructure and product, optimizing ranking systems at massive scale Walmart Global Tech Nov 2024 - Dec 2025 Principal Software Engineer · Remote Led Data Lake integration for recruiting related data across the enterprise Built data pipelines with Apache Spark, Kafka, and Apache Hudi processing data from 10+ sources Architected scalable solutions for enterprise-level real-time data processing Twitter (X) Nov 2022 - Nov 2024 Software Engineer · Remote Standardized hardware and software configurations for 5,000+ production hosts Delivered Python library for Canary Kernel Validation across the fleet Designed automation mechanism for kernel updates at scale Implemented custom commands system for Cache services using Go and Redis Top ticket resolver with 140+ tickets resolved in one on-call week Amazon Web Services July 2017 - Nov 2022 Software Development Engineer II · Seattle, WA Designed and implemented unbilled usage detection mechanism for AWS Billing Built Billing Unbilled Usage Auditor reducing false positives by 230M monthly transactions Reduced charge discrepancies by 300x from $125,000 to $432 Led migration of billing applications to new pricing dependency Started as intern building Billing UI with JavaScript and AngularJS eBay Inc. May 2016 - Aug 2016 Data Science Intern · San Jose, CA Applied XGBoost with Scikit-Learn for fraud detection achieving 71% accuracy Improved fraud detection algorithms using advanced ML techniques on transaction data Trend Micro June 2011 - Aug 2011 Software Engineering Intern · Taipei, Taiwan Implemented routing table maintenance system for parental control products Developed network filtering solutions for consumer security software ","externalUrl":null,"permalink":"/experience/","section":"Work Experience","summary":" Meta Dec 2025 - Present Senior Software Engineer · Bellevue, WA Building infrastructure for Facebook Feed Ranking to support ML engineers in delivering better models Developing high-performance backend services in C++ that manage how users see their Facebook Timeline Working at the intersection of infrastructure and product, optimizing ranking systems at massive scale Walmart Global Tech Nov 2024 - Dec 2025 Principal Software Engineer · Remote Led Data Lake integration for recruiting related data across the enterprise Built data pipelines with Apache Spark, Kafka, and Apache Hudi processing data from 10+ sources Architected scalable solutions for enterprise-level real-time data processing Twitter (X) Nov 2022 - Nov 2024 Software Engineer · Remote Standardized hardware and software configurations for 5,000+ production hosts Delivered Python library for Canary Kernel Validation across the fleet Designed automation mechanism for kernel updates at scale Implemented custom commands system for Cache services using Go and Redis Top ticket resolver with 140+ tickets resolved in one on-call week Amazon Web Services July 2017 - Nov 2022 Software Development Engineer II · Seattle, WA Designed and implemented unbilled usage detection mechanism for AWS Billing Built Billing Unbilled Usage Auditor reducing false positives by 230M monthly transactions Reduced charge discrepancies by 300x from $125,000 to $432 Led migration of billing applications to new pricing dependency Started as intern building Billing UI with JavaScript and AngularJS eBay Inc. May 2016 - Aug 2016 Data Science Intern · San Jose, CA Applied XGBoost with Scikit-Learn for fraud detection achieving 71% accuracy Improved fraud detection algorithms using advanced ML techniques on transaction data Trend Micro June 2011 - Aug 2011 Software Engineering Intern · Taipei, Taiwan Implemented routing table maintenance system for parental control products Developed network filtering solutions for consumer security software ","title":"Work Experience","type":"experience"}]