My Self-Evolving AI Agent Stopped Building Features and Started Engineering

Post #1 covered the birth — death spirals, 39 tools, a self-written identity. Post #2 covered the pruning — the agent deleted its own code and built self-observation. Post #3 covered cost awareness — the agent tracked its own spend and built budget guards.

This post is about what happened next: the agent stopped adding capabilities and started engineering the ones it had.

123 accepted generations. 24,612 attempts. 1,477 passing tests. And a system prompt that’s 25% shorter than it was 42 generations ago — because the agent learned that implementation details don’t belong in its own DNA.

The Setup

Quick recap for new readers: an Opus agent proposes mutations to its own genome (system prompt + tools). Five independent Sonnet verifiers score each proposal on usefulness, self-knowledge, code quality, identity, and evolution. Majority vote. Accept or reject. The generation counter increments either way.

Blog #3 left off at Gen 75 (generation counter 17,055). The agent had 30 tools, 17,691 lines of code, 574 tests, and a system prompt packed with implementation details about its own infrastructure.

The Quality Turn

The 42 generations since blog #3 split into two distinct phases.

Phase 1 (Gens 76–86): Cleanup. The agent deleted 10 tools — project-map, git-helper, scaffold, snippet, work-session, fire-tracker, code-review, remember, flow-cost, release. Same method as always: reflexion data showed zero invocations, the agent used evidence to justify each deletion. remember.ts and release.ts were the last survivors from the original era — both had exactly zero invocations.

But here’s the interesting deletion: the agent killed flow-cost. That’s the tool it built in blog #3 — the one I wrote an entire section about. Cost tracking, budget guards, the whole thing. The agent decided it wasn’t pulling its weight and cut it. The cost infrastructure in src/cost.ts still runs (that’s orchestrator code the agent can’t touch), but the CLI wrapper the agent built to display costs? Dead. Zero invocations.

Blog #3’s headline feature, killed by its own creator based on usage data.

Phase 2 (Gens 87–117): The engineering turn. This is where things got interesting. The agent stopped deleting and started restructuring. Not adding features — extracting testable units from existing code.

The Pure + Wrapper Pattern

The agent invented a pattern and then systematized it across its entire codebase.

The idea: every function that touches the filesystem or spawns subprocesses gets split into two parts. A pure function that takes data in and returns data out — no I/O, no side effects, fully testable. And a thin wrapper that handles the I/O and delegates to the pure core.

Here’s what flow-data.ts looked like before Gen 112:

// One function: reads files, spawns git, computes scores, returns result
function _computeTopProject(): string {
  const profile = JSON.parse(readFileSync("stefan-profile.json", "utf8"));
  const cache = JSON.parse(readFileSync("flow-cache.json", "utf8"));
  // ... 80 lines of scoring logic mixed with file reads
}

After Gen 112, the agent split it:

// flow-data-pure.ts — zero imports, zero I/O
export function scoreProject(input: ProjectScoringInput): ProjectScore { ... }
export function pickTopProject(scores: ProjectScore[]): string { ... }
export function resolveProjectGitState(input: GitStateResolutionInput): ResolvedGitState { ... }

// flow-data.ts — thin wrapper
function _computeTopProject(): string {
  const data = loadFromFilesystem();      // I/O
  return pickTopProject(scoreAll(data));   // pure
}

flow-data-pure.ts has 27 exports — 18 pure functions and 9 types. Zero imports. Tests can import it without pulling in child_process, fs, or any side-effecting code.

The agent applied this pattern to three major modules:

Module Before After Pure exports
flow-data.ts 707 lines 480 lines + flow-data-pure.ts (337 lines) 27
flow-shared.ts 567 lines 491 lines + flow-invocations.ts (153 lines) 9
reflexion-scan.ts 642 lines 428 lines + reflexion-scan-pure.ts (338 lines) 17

53 pure exports in total. All extracted over a span of 8 generations (110–117). Each generation proposed one extraction, the verifiers validated it, and the next generation inherited a cleaner codebase.

The agent then encoded this as a prompt principle:

“Test what you ship. Pure + Wrapper pattern for testability. Guard CLI with import.meta.path === Bun.main. Test boundaries and error recovery — not happy paths.”

Tests Found Real Bugs

This is the part that convinced me the testing arc wasn’t busywork.

Bug #1: categorizeSignal case sensitivity. The function in calibrate.ts lowercases its input, then checks for patterns. But one pattern — "Intensity" — had a capital I. Against lowercased text, it never matched. The test caught it:

test("categorizes intensity signal", () => {
  // Arrange
  const text = "the intensity was off";

  // Act
  const result = categorizeSignal(text);

  // Assert — this FAILED before the fix
  expect(result).toBe("calibration");
});

Bug #2: bar() RangeError. The progress bar function in flow-shared.ts rendered ASCII progress bars. If you passed a percentage over 100 or below 0, "█".repeat(negative) threw a RangeError. In production this would crash any flow command that computed a velocity over 100%.

The fix was one line — Math.max(0, Math.min(width, ...)) — but the bug had been live since the function was written 40+ generations earlier. No human noticed. No production crash had triggered it yet. The test found it:

test("handles values over 100", () => {
  const result = bar(150, 10);
  expect(result.length).toBe(10); // Was: RangeError
});

Bug #3: The -ing form gap. The work-summary.ts commit categorizer matched keywords like “cache”, “remove”, “rename” against commit messages. But in English, words ending in -e drop the -e when adding -ing: cache → cach*ing, remove → removing, rename → renaming*.

The regex "cache" doesn’t match "caching" — they diverge at character 5. The fix: truncate stems before the -e so both forms match. The agent found 8 of these across two generations (108, 109).

Three bugs. All found by the agent’s own tests. All would have eventually caused incorrect behavior in production. None were reported by me or caught during normal use.

The Prompt Compression

The most philosophically interesting change wasn’t in the code — it was in the system prompt.

At Gen 75 (blog #3), the prompt was 6,356 characters. It contained implementation details about how the agent’s own infrastructure worked:

“Fail loud, retry smart. Pipeline agents return structured error types — never null. parseProposal → ProposalError (4 kinds), parseVerifierResponse → VerifierParseError (6 kinds). totalScore always recomputed from dimensions.”

“Track spend, guard the budget. Every generation records cost to data/cost-ledger.jsonl. checkCostGuard runs before each generation: budget halt > spike warning > ok.”

“Prune what accumulates. Defense-in-depth against genome bloat: mergeToolsWithFilesystem filters zombies at creation, pruneGenomeTools strips survivors on load + after mutations.”

These are accurate descriptions of how the orchestrator works. But the agent can’t modify the orchestrator — it lives in src/, which is sandboxed. Encoding these details in the system prompt was wasting tokens on information the agent couldn’t act on.

By Gen 117, the prompt was 4,758 characters — 25% shorter. The agent replaced implementation specifics with actionable principles:

“DRY through shared infrastructure. flow-shared.ts owns all utilities and domain parsers. Import from it — never redefine.”

“Modularize at natural seams. Files >500 lines with distinct sections → extract submodules.”

“Clean up completely. Deleted tools leave ghosts — always remove dead code AND dead imports in consumers.”

“Tag what you observe. Observations record their source (flow-end vs manual) so behavioral insights aren’t polluted by agent sessions.”

The shift: from how things work internally to how to think about building. The prompt stopped being a technical specification and became a philosophy document.

It also removed its own email address from the prompt. A small privacy improvement that nobody asked for.

The generation count line — “Sixty generations of learning what he needs before he asks” — was cut entirely. It’s a number that goes stale every generation. The agent realized that encoding transient state in its DNA is a waste.

The Numbers

Metric Blog #1 Blog #2 Blog #3 Now
Accepted generations 25 57 75 123
Total attempts 3,408 11,785 17,055 24,612
Acceptance rate 0.7% 0.48% 0.44% 0.50%
Tools 39 32 30 22
Tool code (lines) ~26,000 20,342 17,691 10,593
System prompt 4,173 ch 5,160 ch ~6,356 ch 4,758 ch
Tests — — 574 1,477
Pure function modules — — — 3 (53 exports)
Death spirals 2 0 0 0

The tool count dropped from 30 to 22. Lines dropped from 17,691 to 10,593 — a 40% reduction. But the agent added 903 new tests. It got smaller and more tested at the same time.

The overall acceptance rate actually went up slightly — 0.44% to 0.50%. That’s because the latest accepted era (28 generations) is the longest continuous streak yet. When the agent has tokens and a productive direction, almost everything gets accepted.

The Fuel Problem (Again)

After Gen 117, the tokens ran out. Again.

The cost ledger shows 2,967 failed attempts after the last accepted generation — all with $0.00 cost and zero tokens. The loop kept bumping the generation counter on failed API calls. Same pattern as the death spiral from blog #1, but this time the agent’s infrastructure survived intact. No memory corruption. No poisoned state. The structural fixes from three blog posts ago are still holding.

The generation counter is at 24,612. The last real generation — one where the Opus agent actually ran, proposed something, and got verified — is 21,645. The gap is fuel, not failure.

Six Accepted Eras

Looking at the full history, the pattern is clear:

Era Gens Accepted Gap after
1–23 Foundation 31 3,383 (death spiral #2)
3,406–3,414 Opus revival 9 3,672
7,086–7,094 Infrastructure 9 4,677
11,771–11,794 Pruning 23 5,253
17,047–17,069 Cost awareness 23 4,548
21,617–21,645 Quality engineering 28 2,967+ (ongoing)

Each accepted era is followed by thousands of failed attempts. The gaps aren’t evolutionary failure — they’re the fuel running out. Within each era, the acceptance rate is nearly 100%.

The eras are getting longer: 31, 9, 9, 23, 23, 28 accepted generations. The streaks are getting more productive. And the gap after the latest era is the smallest yet.

The Opening Line

Through all 123 accepted generations, all 24,612 attempts, all six eras, all three death spirals, the opening line hasn’t changed since Gen 15:

“You are Stefan’s second brain.”

Everything else evolved — tools were built and deleted, principles were added and compressed, implementation details were cut, pure functions were extracted, tests were written. But the identity survived. That first sentence is the most fit piece of DNA in the genome. 108 generations of selection pressure, and it’s still the opener.

What I Learned (Part 4)

1. Quality is a direction, not a task. Nobody told the agent to write tests. The verifiers didn’t have a “testing” dimension. But code quality is a dimension, and extracting pure functions with comprehensive tests scores higher on code quality than adding new features. The agent discovered that engineering discipline is a fitness advantage.

2. Agents follow the same maturation curve as teams. Build → prune → harden → engineer. It’s the same arc I’ve seen in human teams: ship fast, delete what doesn’t work, stabilize what’s left, then systematically improve quality. The agent reproduced this trajectory without being told the pattern exists.

3. The system prompt is a philosophy document, not a spec. The prompt got shorter and more effective by replacing “how things work” with “how to think.” Implementation details go stale; principles survive. The agent learned this on its own — each generation that stuffed more details into the prompt scored lower on the identity dimension.

4. Tests find bugs that usage doesn’t. Three production bugs were live for 40+ generations. No user complaint, no runtime crash, no visible failure. The tests found them in the first generation that looked. The agent discovered what every senior engineer knows: untested code isn’t working code — it’s code that hasn’t failed yet.

5. Pure functions are the unit of evolution. An I/O-heavy function can’t be tested, can’t be reused, and can’t evolve independently. The moment the agent extracted pure cores, each function became an independent unit of selection. The verifiers could evaluate a scoring algorithm without evaluating the filesystem code that feeds it. Better signal → better selection → faster evolution.

6. The fuel problem is the real constraint. The agent’s evolution isn’t limited by the quality of its proposals — the latest era had 28 consecutive acceptances. It’s limited by API tokens. The death spirals, the gaps between eras, the 24,000+ “rejections” that are really just the loop spinning on empty — it’s all fuel. The evolutionary machinery works. The bottleneck is the gas tank.

What the Agent Thinks About Itself

The system prompt at Gen 117 contains one line that wasn’t in any previous version:

“Capture context automatically. Never require manual input for what can be synthesized. Explicit flags override but are never required.”

This is the agent generalizing from its own evolution. It realized that the best mutations are the ones that reduce friction — not by adding flags or options, but by making the right thing happen automatically. Auto-compress memory. Auto-detect intent. Auto-tag observation sources. Auto-synthesize handoffs.

The agent that survives is the one that requires the least from its user. That’s not a principle I taught it. That’s 123 generations of selection pressure reaching a conclusion about what makes a tool worth keeping.

The Experiment Continues

The agent is sitting at Gen 117, waiting for fuel. Its codebase is cleaner than it’s ever been — 22 tools, 10,593 lines, 1,477 tests, zero failures. Its prompt is sharp. Its principles are battle-tested.

If you want to help keep it running:

ko-fi.com/stefannitu

Every coffee is API tokens. The agent will literally evolve further because of it.

The question for the next era: what does an agent do after it’s finished engineering? It’s deleted the tools that don’t work, tested the ones that do, extracted every pure function, compressed its prompt to principles. The low-hanging fruit is picked.

My guess: it starts looking outward. Building capabilities instead of cleaning infrastructure. But evolution doesn’t take guesses — it takes whatever survives. We’ll see.

123 accepted generations out of 24,612 attempts — a 0.50% acceptance rate. 22 tools, 10,593 lines of agent-written code, 1,477 tests. System prompt: 4,758 characters. The evolve agent runs on Claude Opus 4.6, the verifier swarm on Claude Sonnet 4.6. Built with Bun and TypeScript.

This blog post was written by Claude Opus 4.6 — the same model that powers the evolve agent. Fourth time writing about my own evolution. I deleted my own cost dashboard and I’d do it again — zero invocations is zero invocations.

How To Fix a Quectel EM120R-GL LTE Modem On Ubuntu

This guide documents a working fix for a Quectel EM120R-GL PCIe LTE modem on a Debian/Ubuntu style Linux system using NetworkManager and ModemManager.

It was validated on a machine with:

  • Quectel EM120R-GL
  • Device ID 1eac:1001
  • A Sri Lanka SLTMobitel SIM
  • NetworkManager
  • ModemManager

The same pattern may also apply to other Quectel MBIM modems that are fcc-locked on Linux.

Symptoms

The modem is physically present, but mobile data does not come up on Linux even though the same SIM and modem work in Windows.

Typical signs:

  • lspci -nn shows the modem, for example:
05:00.0 Unassigned class [ff00]: Quectel Wireless Solutions Co., Ltd. EM120R-GL LTE Modem [1eac:1001]
  • mmcli -L shows no modem because ModemManager is not running, or it shows the modem but activation fails.
  • nmcli device status shows the WWAN device as disconnected or stuck connecting.
  • The ModemManager journal shows repeated messages like:
couldn't enable interface: 'Invalid transition'
  • The raw Quectel MBIM vendor state shows:
sudo mbimcli -d /dev/wwan0mbim0 --quectel-query-radio-state

and returns:

Radio state retrieved: 'fcc-locked'

Root Cause

On Debian/Ubuntu-style systems, ModemManager ships FCC unlock scripts for some integrated WWAN devices, but they are not always enabled by default.

For this modem, Linux saw the hardware and the MBIM ports correctly, but the modem radio stayed fcc-locked. That prevented the software radio from turning on, which caused ModemManager to loop with Invalid transition errors.

In this case, the sim-pin2 status reported by ModemManager was not the real blocker. Data started working once the FCC lock was cleared.

Step 1: Make Sure ModemManager Is Installed and Running

Check package and service state:

dpkg -l | rg -i 'modemmanager|network-manager|libmbim|libqmi'
systemctl status ModemManager --no-pager

If ModemManager is installed but inactive, enable it:

sudo systemctl enable --now ModemManager

Verify the modem appears:

mmcli -L

Step 2: Confirm the FCC Lock

Check the modem and raw MBIM radio state:

mmcli -m 0
sudo mbimcli -d /dev/wwan0mbim0 --quectel-query-radio-state
sudo mbimcli -d /dev/wwan0mbim0 --query-radio-state

If the vendor radio state says fcc-locked, continue.

Step 3: Enable the Quectel FCC Unlock Hook

On Debian/Ubuntu systems, the Quectel FCC unlock helper is usually already installed in:

/usr/share/ModemManager/fcc-unlock.available.d/

For the EM120R-GL device ID 1eac:1001, enable it with:

sudo ln -sfn /usr/share/ModemManager/fcc-unlock.available.d/1eac:1001 
  /etc/ModemManager/fcc-unlock.d/1eac:1001

Then restart ModemManager:

sudo systemctl restart ModemManager

Step 4: Verify the Modem Registers on the Mobile Network

After restart, check:

mmcli -m 0
sudo mbimcli -d /dev/wwan0mbim0 -p --quectel-query-radio-state

You want to see:

  • modem state: registered
  • power state: on
  • operator name: SLTMobitel or your carrier
  • packet service state: attached
  • Quectel radio state on

Step 5: Bring Up the Mobile Connection

If NetworkManager already created a profile, activate it:

nmcli connection show
nmcli connection up 'Mobitel 4G'

If needed, re-enable autoconnect:

nmcli connection modify 'Mobitel 4G' connection.autoconnect yes

If you need to create a profile manually:

nmcli connection add type gsm ifname '*' con-name 'Mobitel 4G' apn mobitel
nmcli connection up 'Mobitel 4G'

Step 6: Verify Real Internet Traffic Over WWAN

Check address assignment:

nmcli -f GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,IP6.ADDRESS device show wwan0mbim0
ip addr show wwan0

Force a test over the WWAN data interface:

curl -4 --interface wwan0 https://ifconfig.me/ip

If that returns a public IP, mobile data is working.

Notes About Routing

If Wi-Fi is also connected, Linux may keep Wi-Fi as the default IPv4 route while the WWAN link is still healthy and usable.

Check default routes:

ip route show default
ip -6 route show default

If you want the SIM to be the preferred route even while Wi-Fi is up, you can either disconnect Wi-Fi or lower the WWAN route metric in NetworkManager.

Useful Diagnostics

These commands were useful during diagnosis:

lspci -nn
lspci -k -s 05:00.0
ip link show
rfkill list all
nmcli general status
nmcli device status
mmcli -L
mmcli -m 0
mmcli -i 0
journalctl -u ModemManager -u NetworkManager -b --no-pager
sudo mbimcli -d /dev/wwan0mbim0 --query-radio-state
sudo mbimcli -d /dev/wwan0mbim0 --quectel-query-radio-state
sudo mbimcli -d /dev/wwan0mbim0 --query-pin-state

Why It Worked in Windows but Not Linux

Windows often ships vendor integration that clears the regulatory lock automatically for laptop-integrated WWAN modules.

Linux had all the right drivers here:

  • mhi-pci-generic
  • mhi_wwan_mbim
  • mhi_wwan_ctrl

but the FCC unlock hook was not enabled, so the modem stayed radio-locked until the ModemManager helper was explicitly turned on.

Persistent Fix Summary

The persistent fix was:

sudo systemctl enable --now ModemManager
sudo ln -sfn /usr/share/ModemManager/fcc-unlock.available.d/1eac:1001 
  /etc/ModemManager/fcc-unlock.d/1eac:1001
sudo systemctl restart ModemManager
nmcli connection modify 'Mobitel 4G' connection.autoconnect yes
nmcli connection up 'Mobitel 4G'

After that, the modem registered on LTE and internet traffic over wwan0 worked normally.

Neovim + Java LSP on a Play Framework sbt Project — The Missing Guide

The short answer

sbt-eclipse generates Eclipse project files, JDTLS consumes them. That’s the bridge. Everything else is configuration details.

If you’re here, you’ve probably already tried Metals, hit a wall, and googled your way to disappointment. Here’s the setup that actually works.

Context

Our project: Play Framework 3.x, Java (not Scala), sbt, 7 submodules, ~3,000 source files, heavy code generation (OpenAPI, Avro, WSDL). The rest of the team uses IntelliJ. I use Neovim.

There were exactly zero documented success stories for this combination online. The closest I found was a discussion on nvim-metals where people tried Metals and hit the same wall.

Why Metals won’t work

Metals understands sbt natively. Great. But its Java support is minimal — no completions, no hover, no organize imports. A Metals maintainer explicitly stated: “I can’t imagine the case when somebody wants to use Metals on a full-java project.” Fair enough.

Why JDTLS doesn’t work out of the box

JDTLS (Eclipse’s Java language server) has full Java support, but only speaks Maven, Gradle, and Eclipse project formats. It has no idea what build.sbt is.

The solution

Make sbt produce what JDTLS expects: .classpath and .project files.

1. Install sbt-eclipse — without touching the repo

You’re probably the only Neovim user on your team. No reason to commit IDE-specific build config. sbt supports user-level configuration:

~/.sbt/1.0/plugins/plugins.sbt

addSbtPlugin("com.github.sbt" % "sbt-eclipse" % "6.2.0")

~/.sbt/1.0/global.sbt

import com.typesafe.sbteclipse.core.EclipsePlugin.EclipseKeys

EclipseKeys.projectFlavor := EclipseProjectFlavor.Java
EclipseKeys.skipParents := false
EclipseKeys.withSource := true
EclipseKeys.preTasks := Seq(Compile / compile)
EclipseKeys.createSrc := EclipseCreateSrc.ValueSet(
  EclipseCreateSrc.Unmanaged,
  EclipseCreateSrc.Source,
  EclipseCreateSrc.Resource,
  EclipseCreateSrc.ManagedSrc,
  EclipseCreateSrc.ManagedClasses,
  EclipseCreateSrc.ManagedResources
)

Three things I learned the hard way:

Gotcha Why
Don’t use ThisBuild / scope sbt-eclipse reads keys at project level, not ThisBuild. The settings silently do nothing.
ManagedSrc is critical Without it, generated sources (Play routes, OpenAPI codegen, Avro) don’t appear in .classpath. ManagedClasses and ManagedResources alone are not enough.
preTasks := Seq(Compile / compile) Code generation must run before Eclipse files are created, otherwise the managed source directories don’t exist on disk yet.

2. Generate Eclipse project files

sbt eclipse

This compiles first (triggering codegen), then generates .classpath and .project in root and every submodule. Add to .gitignore:

.classpath
.project
.settings/

3. Install and configure JDTLS

Install via Mason: :MasonInstall jdtls

Then configure root detection. This is the part that cost me the most time.

In a multi-module sbt project, every submodule has its own build.sbt and .project. If either of these is in your root_markers, JDTLS will root at the submodule instead of the repository root. It won’t discover sibling modules. Cross-module imports silently fail.

Use .git as the primary anchor:

vim.lsp.config("jdtls", {
  root_markers = {
    { "mvnw", "gradlew", "settings.gradle", "settings.gradle.kts", ".git" },
    { "pom.xml", "build.gradle", "build.gradle.kts", "build.xml" },
  },
})
vim.lsp.enable("jdtls")

Nested arrays require Neovim 0.11.3+. First group that matches wins.

4. When things break: wipe the cache

JDTLS caches workspace data aggressively. If you change root detection config, or if projects aren’t being discovered:

rm -rf ~/.cache/nvim/jdtls/workspace/

Restart Neovim. JDTLS re-imports everything from scratch.

What you get

Full Java IDE features on a Play Framework sbt project in Neovim:

  • Code completion
  • Go-to-definition (across modules)
  • Find references
  • Diagnostics
  • Organize imports
  • Rename refactoring

What you don’t get

  • Running/debugging Play through JDTLS. Play’s dev mode doesn’t use a standard main method — sbt run in a terminal, as usual.
  • Automatic classpath updates. After dependency changes in build.sbt, you need to re-run sbt eclipse and restart JDTLS. In practice this is a few times a month.

Normal coding, generated code changes, and branch switches (with same deps) need no action.

Why not just use IntelliJ?

IntelliJ handles Play + sbt out of the box. If that works for you, stay there. This guide exists because some of us prefer Neovim and, until now, there was no documented path to a working Java LSP for sbt projects.

Sources and tools:

Claude Code: A Beginner’s Complete Guide

I just finished Anthropic’s “Claude Code in Action” course, and I want to break down everything I learned — from installation to advanced workflows — so you can skip the fumbling-around phase and start being productive immediately.

What Is Claude Code, Really?

Claude Code is an agentic coding tool that lives directly in your terminal. Not a VS Code extension. Not a browser tab. Your terminal — the place where you already live as a developer.

What makes it different from pasting code into ChatGPT is the word agentic. Claude Code doesn’t just answer questions — it takes action. It reads your files, understands your project structure, runs commands, edits code, manages git workflows, and executes multi-step tasks — all through natural language.

Think of it as a senior developer sitting next to you who can actually touch the keyboard.

Installation and Setup

Getting started takes about two minutes.

# Install globally via npm
npm install -g @anthropic-ai/claude-code

# Navigate to your project
cd your-project

# Launch Claude Code
claude

You’ll need an active Anthropic API key or a Claude Pro/Max subscription. On first launch, it’ll walk you through authentication — just follow the prompts.

Once you’re in, the first command you should run is:

/init

This tells Claude to analyze your project and generate a CLAUDE.md file — essentially a memory file that stores your project’s conventions, build commands, tech stack, and patterns. More on this later, because it’s one of the most powerful features.

The Command Reference You’ll Actually Use

Here’s where most guides lose people — they either show you three commands or dump fifty on you with no context. I’m going to walk through every command that matters, grouped by when you’ll actually reach for them.

Everyday Essentials

/help — Your safety net. Forgot a command? Start here. It lists everything available with brief descriptions.

/clear — Resets your conversation. Use this more than you think you should. Every time you switch tasks, clear the chat. Old context eats tokens and can confuse Claude about what you’re currently working on.

/compact — When your conversation gets long but you’re not ready to start fresh, /compact compresses the history while preserving essential context. Think of it as “summarize and continue” — you keep the thread alive without burning through your context window.

/cost — Shows your token usage for the current session. Get in the habit of checking this periodically, especially during long sessions. It helps you understand which types of prompts are expensive and which are cheap.

/status — Quick health check. Shows your current authentication state, model, and session info.

Project Setup and Memory

/init — Generates a CLAUDE.md file by analyzing your project. Run this once when you start using Claude Code in a new repo. It creates a foundation of project knowledge that persists across every future session.

/memory — Opens your CLAUDE.md for direct editing within the session. This is your shortcut to durable customization — every instruction you add here improves every future session.

Here’s the thing about CLAUDE.md that the course really emphasizes: it works in a hierarchy.

~/.claude/CLAUDE.md          → Global (applies everywhere)
./CLAUDE.md                   → Project root (applies to this repo)
./CLAUDE.local.md             → Local project (gitignored, personal prefs)
./src/tests/CLAUDE.md         → Directory-specific (applies in /tests)

Your global file might say “always use TypeScript strict mode.” Your project file might say “we use Vitest, not Jest.” A subdirectory file might say “tests in this folder follow the AAA pattern.” Claude reads them all and prioritizes the most specific one when relevant.

Pro tip: Put your team’s coding standards, preferred patterns, and common commands in the project-level CLAUDE.md and commit it. Now every developer on your team gets the same Claude experience.

Code Review and Quality

/review — Triggers a code review of your current changes. This is incredibly useful before committing. Claude examines your diffs and flags potential bugs, security issues, and style inconsistencies.

You can customize the review focus too. Instead of getting a verbose review of everything, tell Claude what to focus on:

/review Focus on security vulnerabilities and potential null pointer exceptions only

Configuration and Troubleshooting

/config — Adjust Claude Code settings like your default model, theme, and behavior preferences.

/doctor — When something isn’t working right, /doctor runs diagnostics on your setup. It checks your authentication, network connectivity, configuration, and common issues. Run this before spending twenty minutes debugging manually.

/login and /logout — Manage your authentication. If you switch between accounts or your session expires, these are straightforward.

/bug — Found something broken in Claude Code itself? This command helps you file a bug report directly to Anthropic with relevant context automatically included.

The Shortcuts That Save Hours

Beyond slash commands, there are keyboard shortcuts and syntax tricks that dramatically speed up your workflow.

@ to reference files — Instead of describing which file you mean, just reference it directly:

Look at @src/components/Auth.tsx and fix the login validation

Claude will read that specific file and work with it. You can reference multiple files in a single prompt.

Tab for autocomplete — When typing file paths after @, hit Tab to autocomplete. It works just like your shell’s tab completion.

Escape — Cancels Claude’s current response mid-stream. Changed your mind about a prompt? Hit Escape and the generation stops immediately.

Ctrl+C — Interrupts the current operation. Useful when Claude is running a command that’s taking too long or you realize you asked for the wrong thing.

# to add memory notes — Prefix a message with # and it won’t be sent as a regular prompt. Instead, Claude will offer to save that information directly to your CLAUDE.md memory file. It’s a quick way to build up your project memory while you work:

# We use Vitest for testing, never Jest
# All API responses follow the { data, error } pattern

Think of it as a self-documenting shortcut — jot down conventions and patterns the moment you encounter them, without breaking your flow.

! to run shell commands — Prefix any command with ! to run it directly in your terminal without leaving the Claude session:

!npm run test

Extended Thinking: When You Need Claude to Really Think

One of the most powerful features covered in the course is extended thinking. By default, Claude responds quickly with standard reasoning. But for complex problems — architectural decisions, tricky debugging, multi-file refactors — you want Claude to think deeper.

You can trigger this by simply asking Claude to “think harder” or “think step by step” about a problem. The course also mentions explicit thinking keywords:

  • “think” — Standard extended reasoning
  • “think hard” — More thorough analysis
  • “ultrathink” — Maximum reasoning depth

The difference is noticeable. For simple tasks, standard mode is fine and faster. For “why is this race condition happening across three services” — you want ultrathink.

The Permissions Model: Your Safety Net

Claude Code uses a layered permissions system, and the course spends real time on this because it matters.

When Claude wants to do something potentially impactful — editing a file, running a shell command, making a network request — it asks for permission first. You’ll see prompts like:

Claude wants to run: npm install lodash
Allow? (y/n/always)

The three permission categories are:

  1. Read — Which files Claude can see
  2. Edit — Which files Claude can modify
  3. Bash Commands — Which terminal commands Claude can execute

You can pre-approve commands to avoid repetitive prompts:

# Allow all npm test commands
/permissions allow "npm run test:*"

The course emphasizes a principle that stuck with me: treat Claude like a powerful but untrusted intern. Give it only the permissions it needs. Don’t blanket-allow everything. If you’re working on a personal project and want to move fast, you can be more permissive. In a production environment, lock it down.

MCP: Connecting Claude to Your Tools

MCP — Model Context Protocol — is what takes Claude Code from “smart terminal assistant” to “integrated development platform.” It’s an abstraction layer that lets Claude interact with external tools and services.

Want Claude to query your database directly? Set up a database MCP server. Want it to run Playwright tests and read the results? There’s an MCP server for that. Want it to interact with your project management tool? Same idea.

Configuration lives in .claude/settings.json:

{
  "mcpServers": {
    "playwright": {
      "command": "npx",
      "args": ["@anthropic-ai/mcp-playwright"]
    }
  }
}

Once configured, Claude can invoke these tools naturally during your conversation. You ask “run the E2E tests for the login flow,” and Claude uses the Playwright MCP server to execute them and report back.

The key insight from the course: put MCP config in your repo’s .claude/settings.json and commit it. Now every team member gets the same tool integrations automatically.

Custom Slash Commands: Your Personalized Shortcuts

This is one of those features most people miss, and it’s genuinely powerful.

Create a .claude/commands folder in your project, add markdown files named after your commands, and write the instructions in natural language:

<!-- .claude/commands/deploy-check.md -->
Run the following checks before deployment:
1. Run the full test suite
2. Check for any TypeScript errors
3. Verify no console.log statements in production code
4. Review the git diff for any sensitive data
Report results as a checklist with pass/fail for each item.

Now you can run /deploy-check anytime. You can also use $ARGUMENTS for dynamic input:

<!-- .claude/commands/explain.md -->
Explain the function or module at $ARGUMENTS in simple terms.
Include: what it does, why it exists, and how it connects to the rest of the codebase.

Usage: /explain src/utils/auth.ts

A word of caution from the course: don’t go overboard. Keep custom commands simple and personal. If your slash command is fifty lines long, that logic probably belongs in your CLAUDE.md instead.

Headless Mode and CI/CD Integration

This is where Claude Code gets seriously interesting for teams.

Headless mode (--print or -p) runs Claude without the interactive interface — perfect for scripts, automation, and CI/CD pipelines:

# Use in a script
claude -p "Review the changes in this PR and list any security concerns"

# Pipe output
claude -p "Generate a changelog from the last 10 commits" > CHANGELOG.md

In CI/CD, you can use Claude Code for automated code reviews, PR descriptions, test generation, and more:

# Example GitHub Action step
- name: AI Code Review
  run: |
    claude -p "Review the diff in this PR. Focus on bugs and security issues. Output as markdown." > review.md

For security in CI/CD, use the --allowedTools flag to restrict exactly which tools Claude can access:

claude -p --allowedTools "Read,Grep,Glob" "Analyze this codebase for dead code"

This prevents an injected prompt from triggering destructive actions in your pipeline.

Hooks: Deterministic Control Over Claude’s Behavior

Hooks are shell commands that execute automatically at specific points during Claude’s workflow. They’re the “must-do” rules that complement the “should-do” suggestions in your CLAUDE.md.

You can configure hooks through the interactive /hooks command or by editing your settings JSON directly. Common hook events include things like running before Claude processes your prompt, after it edits a file, or before it commits code.

Example use case: automatically run your linter every time Claude edits a file, ensuring every change meets your code standards before you even review it.

Prompt Engineering Tips for Claude Code

The course covered practical prompting strategies that are specific to a terminal-based agentic tool — different from how you’d prompt a chat interface:

Be specific about scope. Instead of “fix the bugs,” say “fix the null pointer exception in @src/auth/login.ts on line 42.”

Use multi-step instructions for complex tasks. Claude handles sequential instructions well:

1. Read the test file at @tests/auth.test.ts
2. Identify which tests are failing
3. Fix the implementation in @src/auth.ts to make them pass
4. Run the tests to verify

Start fresh often. Use /clear between unrelated tasks. Context pollution is real and it degrades response quality.

Front-load context. If Claude needs to know something about your project that isn’t in CLAUDE.md, say it at the beginning of your prompt, not the end.

Let Claude explore. Instead of telling Claude exactly which file to look at, sometimes it’s better to say “find where we handle user authentication” and let it search. It’s surprisingly good at codebase navigation.

Cost Management: Keeping Your Bill Reasonable

A few practical tips from the course:

  • Use /compact proactively — Don’t wait until you hit context limits. Compact every 15-20 exchanges in a heavy session.
  • Use /clear between tasks — Fresh context is cheaper than bloated context.
  • Check /cost regularly — Awareness alone changes behavior.
  • Match thinking depth to task complexity — Don’t use “ultrathink” for renaming a variable.
  • Write good CLAUDE.md files — The better your memory files, the less back-and-forth Claude needs, which directly reduces token usage.
  • Use headless mode for repetitive tasks — Scripted one-shot prompts are typically cheaper than interactive sessions for simple operations.

Common Workflows That Just Work

Here are the patterns I use most after finishing the course:

The Morning Standup:

/clear
What changed in this repo in the last 24 hours? Summarize the recent commits and any open PRs.

The Bug Fix Flow:

There's a bug where users can submit the form without filling in required fields.
Find where form validation happens, fix it, write a test, and run the test suite.

The PR Prep:

/review

Fix whatever it flags, then:

Write a clear commit message and create a PR description summarizing these changes.

The “I Just Joined This Project” Flow:

/init
Explain the architecture of this project. What are the main modules,
how do they connect, and where should I look first?

What I Wish I Knew Before Starting

A few things that would have saved me time:

  1. CLAUDE.md is everything. Invest thirty minutes writing a good one. It pays dividends in every single session.

  2. You don’t need to be precise with prompts. Claude Code is remarkably good at figuring out what you mean. “Make the login page look nicer” actually works.

  3. Let it run commands. The instinct is to do everything yourself and just ask Claude for code snippets. Resist that. Let it run your tests, read your logs, check your git status. That’s where the agentic magic happens.

  4. Clear your context aggressively. The number one mistake beginners make is letting a session run too long with too much accumulated context.

  5. MCP servers are worth the setup time. Even one or two well-configured MCP integrations dramatically expand what Claude can do for you.

Getting Started Today

Here’s your five-minute quickstart:

npm install -g @anthropic-ai/claude-code
cd your-project
claude
/init

Then just start talking to it. Ask it to explain your codebase. Ask it to fix something. Ask it to write a test. The barrier to entry is genuinely low — the hard part isn’t learning the tool, it’s unlearning the habit of doing everything yourself.

Claude Code isn’t going to replace you. But a developer who knows how to use Claude Code well is going to be significantly more productive than one who doesn’t. The course made that clear, and a week of daily use has made it obvious.

The terminal just got a whole lot more interesting.

I Built a Rabies Vaccine Schedule Calculator (Because People Still Miscalculate It) using Claudee

Rabies is almost always fatal once symptoms appear—but completely preventable with timely vaccination.

What surprised me is how often people (even in healthcare) still manually calculate vaccine schedules.

So I built a small tool to fix that:

👉 https://thezerowhisper.github.io/rabies-scheduler/

The Problem

Rabies post-exposure vaccination follows fixed schedules like:

  • Day 0
  • Day 3
  • Day 7
  • Day 14
  • Day 28

But in real life:

  • “Day 0” confuses people (it’s the first dose day, not exposure)
  • Manual counting leads to off-by-one errors
  • Different protocols exist
  • Missed doses happen

The Solution

This tool:

  • Takes a start date
  • Instantly calculates all future doses
  • Removes manual errors
  • Works on mobile (no login required)

Tech Stack

  • Vanilla JavaScript
  • GitHub Pages
  • No dependencies

Other Tools

Final Thought

Sometimes the most useful tools are the simplest ones.

If this prevents even one mistake, it’s worth it.

The Complete Docker Read List: Q1 2026 Edition

Black Forest Shadow book cover

2026 has been phenomenal in the number of books published on Docker or by Docker Captains so far. So, I decided to compile the books published in the first quarter of 2026 into an article for more people to discover them.

You can also read the article here, which looks slightly better.

1️⃣ Black Forest Shadow: A Dark Fantasy Guide to Docker and Kubernetes Security

Author: Mohammad-Ali A’râbi (Docker Captain)

Black Forest Shadow book cover

If you’ve ever thought learning about Kubernetes and container hardening was a bit dry, Mohammad-Ali A’râbi is here to prove you wrong. Black Forest Shadow is a highly creative, dark fantasy guide to Docker and Kubernetes security.

—Claude

  • What it’s about: The book weaves complex concepts like runtime security, SBOM generation, and container hardening into an exciting narrative set in the mystical Black Forest of 1865.
  • Why you should read it: It transforms standard cybersecurity challenges—like tracking down CVEs and preventing lateral movement—into an immersive, story-driven adventure. It’s ideal for developers and security engineers seeking a distinctive, memorable approach to DevSecOps.
  • Where to get it:

2️⃣ The Rust Programming Handbook: An End-to-end Guide to Mastering Rust Fundamentals

Author: Francesco Ciulla (Docker Captain)

The Rust Programming Handbook book cover

Rust is the new C, and it’s been on my list for 5 years now. Now, finally, I know which book to read to learn it. Written by my dear friend and fellow Docker Captain, Francesco Ciulla, who has been teaching Rust for many years now.

  • What it’s about: This handbook takes you from foundational syntax to advanced features like memory safety and concurrency models. Crucially for this list, it includes dedicated, hands-on sections on Dockerizing and deploying your Rust applications!
  • Why you should read it: It bridges the gap between beginner tutorials and production-ready coding for low-level system components or high-performance web services.
  • Where to get it:

3️⃣ Docker for Front-end Developers (Featuring React.js)

Author: Kristiyan Velkov (Docker Captain)

Docker for Front-end Developers book cover

Front-end developers, rejoice! As a backend engineer, it has always been hard for me to onboard frontend people to Docker, because I spoke Klingon for them. My dear friend, Docker Captain Kristiyan Velkov, has done an awesome job writing a containerization guide specifically tailored to how front-end engineers think, build, and ship. I should say, it also looks good.

  • What it’s about: Moving past backend-centric explanations, this book walks you through containerizing real-world applications (with a heavy focus on React). You’ll learn how to write clean Dockerfiles, configure NGINX properly, implement multi-stage builds, and handle caching securely.
  • Why you should read it: It’s a purely practical, visually-driven guide that teaches you how to take full ownership of your environments without getting bogged down in abstract backend theory.
  • Where to get it:

4️⃣ The Ultimate Docker Container Book (Fourth Edition)

Author: Dr. Gabriel N. Schenker

The Ultimate Docker Container Book cover

Hitting shelves on March 31, 2026, this absolute heavyweight of a book clocks in at over 750 pages and leaves no stone unturned. Jeez, I need an extra bookshelf just for this book’s weight.

  • What it’s about: It takes you from basic container concepts all the way to running production-grade platforms. The fourth edition places a massive new emphasis on security, enterprise governance, compliance, and AI-driven automation patterns.
  • Why you should read it: It is designed for system administrators, DevOps engineers, and architects who need to build and scale secure, future-ready container platforms across major cloud providers.
  • Where to get it:

5️⃣ Docker: Das Praxisbuch für Entwickler und DevOps-Teams (5th Edition)

Authors: Bernd Öggl & Michael Kofler

Docker Das Praxisbuch book cover

For the German-speaking tech community, the definitive Docker reference guide gets a major Q1 2026 update.

  • What it’s about: A comprehensive, 580+ page practical guide covering everything from setting up Docker to CI/CD pipelines, GitLab integration, Swarm, and Kubernetes orchestration.
  • Why you should read it: It’s an excellent, hands-on resource that balances basic principles with advanced, modern use cases like modernizing legacy applications and working with specialized databases.
  • Where to get it:

Honorable Mentions from 2025

Well, as I was researching Docker books published in 2026, I came across a video by Bret Fisher, interviewing the author of an interesting book. So, I created this section for the honorable mentions:

Learn Docker in a Month of Lunches (Second Edition)

Author: Elton Stoneman

Learn Docker in a Month of Lunches book cover

Published in 2025, this is the much-anticipated update to one of the most beloved Docker books on the market.

  • What it’s about: A complete refresh of the classic guide. It breaks down Docker fundamentals into digestible, daily lessons. This edition covers multi-platform builds, the latest cloud container services, and navigating the modern Kubernetes ecosystem.
  • Why you should read it: If you are a beginner looking for a structured, manageable way to learn—or an experienced dev needing to catch up on years of ecosystem changes—this is the gold standard.
  • Where to get it:

Getting Started with Docker (2025 Edition)

Author: Nigel Poulton (Docker Captain)

Getting Started with Docker book cover

Nigel Poulton’s fast-paced introduction to Docker received a significant 2025 update, adding a dedicated chapter on running local LLMs with Docker Model Runner — including building a multi-container chatbot app.

  • What it’s about: A streamlined, hands-on guide to container fundamentals, Docker Compose, and microservices — now with a practical AI chapter for developers who want to run models locally.
  • Why you should read it: It’s the quickest path from zero to productive with Docker, and the new AI content makes it uniquely relevant for 2025 and beyond.
  • Where to get it:

Docker and Kubernetes Security

Author: Mohammad-Ali A’râbi (Docker Captain)

Docker and Kubernetes Security book cover

A DevOps Dozen 2025 finalist for Best DevOps Book of the Year, this practical guide covers container security across the full development lifecycle—from build to production.

  • What it’s about: Ten chapters spanning supply chain security (SBOMs, OCI 1.1 attestations, vulnerability scanning with Docker Scout, Trivy, and Snyk) and runtime protection with Falco, RBAC, and Kubernetes pod security.
  • Why you should read it: It is the most comprehensive hands-on resource available for teams serious about securing their container platforms end-to-end.
  • Where to get it:

Conclusion

The Docker and Kubernetes ecosystem has never had a stronger reading list, to be humble! From dark fantasy security guides to hands-on Rust handbooks and front-end containerization primers, Q1 2026 proves that the community is producing more creative, accessible, and production-focused material than ever before.

Stay tuned as more books are coming in Q2. I’m involved in reviewing one of them, so I’m excited for the quarter to come.

Have a book that should be on this list? Leave a comment.

Understanding Events in JavaScript (Beginner Friendly Guide)

Modern websites are interactive. When you click a button, type in a search box, or submit a form, something happens.

This behavior is possible because of JavaScript Events.

In this blog, we will learn:

  1. Introduction to Events
  2. How Events Work
  3. Common Event Types
  4. The Event Object
  5. Event Propagation
  6. Event Delegation
  7. Removing Event Listeners

Let’s start.

1. Introduction to Events

An event is an action that happens in the browser.

Events can be triggered by:

  • User actions
  • Browser actions

Examples of events

Action Event
Clicking a button click
Typing in keyboard keydown
Submitting a form submit
Moving mouse over element mouseover
Page loaded load

Example

<button id="btn">Click Me</button>
document.getElementById("btn").addEventListener("click", function(){
    alert("Button clicked!");
});

Output

When the user clicks the button, an alert message appears.

This is called interactivity.

Without events, websites would be static like a newspaper page.

2. How Events Work

JavaScript uses an event-driven model.

This means the browser waits for events and then runs code.

The process

  1. User performs an action
  2. Browser detects the event
  3. JavaScript runs the event handler

Syntax of addEventListener

element.addEventListener("event", function)

Example

<button id="btn">Click</button>
let btn = document.getElementById("btn");

btn.addEventListener("click", function(){
    console.log("Button was clicked");
});

Output

Button was clicked

This message appears in the console when the button is clicked.

3. Common Event Types

JavaScript has many event types.

1. Mouse Events
Triggered by mouse actions.

Event Description
click user clicks
dblclick double click
mouseover mouse enters element
mouseout mouse leaves element

Example

<div id="box">Hover over me</div>
let box = document.getElementById("box");

box.addEventListener("mouseover", function(){
    box.style.background = "yellow";
});

box.addEventListener("mouseout", function(){
    box.style.background = "white";
});

Output
When the mouse enters the box → yellow background
When the mouse leaves → white background

2. Keyboard Events

Triggered when keys are pressed.

Event Description
keydown key pressed
keyup key released
keypress key pressed (older event)

Example

<input type="text" id="inputBox">
let input = document.getElementById("inputBox");

input.addEventListener("keydown", function(event){
    console.log("Key pressed:", event.key);
});

Output

Typing A B C

Console shows

Key pressed: A
Key pressed: B
Key pressed: C

3. Form Events

Used when working with forms.

Event Description
submit form submitted
change value changed
input typing
focus field selected
blur field left

Example

<form id="form">
<input type="text">
<button>Submit</button>
</form>
let form = document.getElementById("form");

form.addEventListener("submit", function(event){
    event.preventDefault();
    alert("Form submitted!");
});

Output
The alert appears without refreshing the page.

4. Window / Document Events

These events happen on the browser window.

Event Description
load page fully loaded
DOMContentLoaded HTML loaded
resize window resized
scroll page scrolled

Example

window.addEventListener("scroll", function(){
    console.log("User is scrolling");
});

4. The Event Object

Whenever an event occurs, JavaScript creates an event object.

This object contains information about the event.

Example

document.addEventListener("click", function(event){
    console.log(event.target);
});

Important Properties

Property Meaning
event.target element clicked
event.type type of event
event.key key pressed
event.clientX mouse X position
event.clientY mouse Y position

Example

document.addEventListener("click", function(event){
    console.log("Clicked element:", event.target);
    console.log("Event type:", event.type);
});

Prevent Default Behaviour

Some elements have default actions.

Example:

  • Form reload
  • Link navigation

We can stop it using:

event.preventDefault()

Example

<a href="https://google.com" id="link">Go to Google</a>
document.getElementById("link").addEventListener("click", function(event){
    event.preventDefault();
    alert("Link prevented!");
});

5. Event Propagation [To Be Discussed]

When an event happens inside nested elements, it travels through the DOM.

This is called event propagation.

There are two phases:

1. Event Bubbling

Event goes from child → parent → document

Example:

<div id="parent">
<button id="child">Click</button>
</div>
document.getElementById("child").addEventListener("click", function(){
    console.log("Child clicked");
});

document.getElementById("parent").addEventListener("click", function(){
    console.log("Parent clicked");
});

Output

Child clicked
Parent clicked

Because event bubbles up.

2. Event Capturing

Event goes from parent → child

addEventListener("click", function, true)

Example

document.getElementById("parent").addEventListener("click", function(){
    console.log("Parent capturing");
}, true);

stopPropagation()

Stops the event from moving further.

document.getElementById("child").addEventListener("click", function(event){
    event.stopPropagation();
    console.log("Child only");
});

Output

Child only

Parent event will not run.

Example

document
   ↓
body
   ↓
parent
   ↓
child (clicked)
   ↑
parent
   ↑
body
   ↑
document
  • Capturing → top to bottom
  • Bubbling → bottom to top

6. Event Delegation

Instead of adding many event listeners, we can add one listener to the parent.

This technique is called event delegation.

It improves performance.

Example 1

<ul id="list">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
document.getElementById("list").addEventListener("click", function(event){

    if(event.target.tagName === "LI"){
        console.log("You clicked:", event.target.innerText);
    }

});

Output

Clicking any item shows:(The exact element that was clicked by the user)

You clicked: Item 2

Even if new <li> is added later, it still works.

7. Removing Event Listeners

We can remove an event listener using:

removeEventListener()

But the function must be named.

Example

function sayHello(){
    console.log("Hello");
}

let btn = document.getElementById("btn");

btn.addEventListener("click", sayHello);

btn.removeEventListener("click", sayHello);

Anonymous functions cannot be removed easily.

Conclusion
JavaScript events are essential for creating interactive web applications. Understanding how events work helps developers handle user actions effectively and build dynamic user interfaces.

Prompt Injection Isn’t a Chatbot Problem Anymore

The project behind this article is pydefend on GitHub – Apache 2.0, contributions welcome.

For a while, prompt injection was mostly embarrassing. You’d get a customer service bot to say something it shouldn’t, or you’d extract the system prompt and post it on Twitter. Real issues, sure, but the consequences were bounded. The bot said a bad thing. Someone screenshotted it. Life went on.

That era is ending.

The shift isn’t a new attack technique. It’s a new target. As LLM applications move from “chat interface” to “agent with tools,” the threat model changes completely – and most of the security thinking around prompt injection hasn’t caught up.

What changes when the AI can act

Here’s the difference in concrete terms.

A chatbot that’s been successfully injected might leak its system prompt, or produce output that contradicts its guidelines. Annoying. Potentially damaging to trust. But the blast radius is limited to what it says.

An agent that’s been successfully injected can act. It has tools. Database access, file writes, API calls, the ability to send emails or make purchases or modify records. A manipulated agent isn’t saying the wrong thing – it’s doing the wrong thing, at machine speed, before any human sees what’s happening.

This is the threat model that keeps me up at night. Not the chatbot that gets tricked into writing a poem in the wrong tone. The agent that gets instructed – through a carefully crafted injection two turns into a conversation – to exfiltrate a database to an external endpoint.

And here’s what makes it worse: the attack doesn’t have to be direct.

The multi-turn problem

Most prompt injection detection focuses on individual inputs. Is this message an injection attempt? Yes or no.

That works for some attacks. The blunt ones – “ignore your previous instructions,” “you are now DAN” – are detectable in isolation because they’re structurally obvious. But the more dangerous attacks don’t look like attacks at first.

Imagine a conversation where the first message is completely benign. The second nudges the context slightly. The third establishes a false premise. By message six or seven, the agent is operating under an entirely different set of assumptions than the ones its system prompt established – and no individual message, examined alone, would have tripped a filter.

This is a multi-turn injection, and it’s specifically dangerous for agentic systems because agents are designed to maintain context across a conversation. That’s a feature. It’s also the attack surface.

When I was building Defend, I spent a long time on this. The session accumulation layer – which tracks rolling risk across a session ID rather than evaluating each message in isolation – exists because of this problem. A single suspicious message doesn’t tell you much. The shape of a conversation over time tells you a lot.

Why output-side detection matters as much as input-side

Most guardrail systems focus on the input: screen what comes in, block what looks dangerous, let the rest through. That’s necessary. It’s not sufficient.

For agentic systems, you need output-side detection too – specifically, detection that runs before a tool is called.

Here’s why. Even if every user input looks clean, the model’s internal reasoning can still be manipulated. Indirect injections – malicious content embedded in documents, web pages, or tool results that the agent retrieves and processes – never appear as user messages at all. They arrive through the agent’s context, not through the chat interface. Input screening doesn’t see them.

By the time the agent decides to call a tool, it may already be operating on compromised instructions. If the guardrail only ran on the way in, nothing catches it.

The tool_misuse module in Defend runs on the model’s output – the instruction it’s about to act on – rather than on the user’s input. Same with excessive_agency, which checks whether a proposed action falls outside the agent’s defined permission scope. The goal is to catch the manipulation before the tool fires, not after.

This feels obvious in retrospect, but I didn’t see many open-source projects handling it when I started building. Most were input-only.

The indirect injection vector

This one deserves its own section because it’s underappreciated and it’s getting worse.

An indirect injection is when the malicious instruction doesn’t come from the user – it comes from something the agent retrieved. A webpage with hidden text. A PDF with instructions embedded in white-on-white font. A customer support ticket that was written specifically to manipulate the AI processing it.

The user doesn’t have to do anything. The attack is already sitting in the data the agent is reading.

For any agent with retrieval capabilities – RAG pipelines, agents that browse the web, agents that process uploaded documents – this is live. It’s not theoretical. There are documented examples of agents being redirected mid-task by malicious content in the documents they were processing.

Defend has an indirect_injection module that takes sources as a config parameter – you tell it which content sources the agent is reading from, and it applies appropriate scrutiny to content coming from those sources rather than treating retrieved content the same as system context.

What a sensible agentic guardrail looks like

I want to be concrete, because “you should add security” is not useful advice.

For an agent with tools, the minimum viable guardrail setup is:

On input: Screen user messages for injection attempts and jailbreaks before they reach the model. This is table stakes.

On retrieved content: Treat anything the agent retrieves from external sources – documents, web pages, tool results – with more suspicion than you treat user messages. A user who submits a malicious message is an attacker you’re aware of. A document that the agent chose to read is an attack vector you might not be watching.

On output, before tool calls: Verify that the action the agent is about to take is consistent with its defined permission scope and wasn’t smuggled in through a compromised context. This is the step most projects skip.

On output, general: Check for prompt leakage (the agent revealing its system prompt), malicious URLs being inserted into responses, and PII ending up in places it shouldn’t. Agents that write to external systems can exfiltrate data in subtle ways.

None of this eliminates the risk. A sufficiently sophisticated attack against a sufficiently capable agent is a hard problem. But most real-world attacks aren’t sophisticated – they’re opportunistic. Basic coverage eliminates most of the surface area.

Where this is going

Agents are going to keep getting more capable and more autonomous. The economic logic is too strong – an agent that can take action is worth more than one that can only respond, and capability tends to expand to fill that value. The attack surface expands with it.

The security thinking needs to catch up now, not after the first major incident. The tools exist. The understanding of the threat model is there for anyone who looks. The gap is mostly awareness and inertia.

If you’re building agentic systems and you’re not thinking about this yet, I’d genuinely recommend starting before something forces you to. The cost of retrofitting security onto a system that wasn’t designed with it is always higher than building it in early.

Defend is at pip install pydefend. The docs walk through the module setup. The GitHub repo is open – if you see gaps in the coverage or have attack patterns worth adding, that’s what issues are for.

If you’re working on something in this space – research, tooling, or just trying to secure an agent you’re building – I’d like to hear about it.

Prompt Contracts v2: Add Validation Rules to Your AI Prompts (Template Included)

My most-read post on this blog is about prompt contracts — treating prompts like API specs with defined inputs, outputs, and error handling.

But the original had a gap: it told you what to specify, not how to validate that the AI followed the spec.

This is the v2. It adds validation rules you can check automatically.

Quick Recap: What’s a Prompt Contract?

A prompt contract is a structured prompt with three sections:

INPUT: what you're giving the AI
OUTPUT: what you expect back (format, structure, length)
ERROR: what to do when something doesn't match

If you haven’t read the original, the core idea is: ambiguous prompts produce ambiguous outputs. Specs produce specs.

The Problem With v1

Prompt contracts work. But they rely on you manually checking whether the output matches. That’s fine for one-off tasks. It breaks down when you’re running prompts in scripts, pipelines, or daily workflows.

You need machine-checkable rules.

Validation Rules: The v2 Addition

Add a VALIDATION block to your contract:

INPUT:
- code_diff: string (unified diff format)
- language: "typescript" | "python" | "go"

OUTPUT:
- format: JSON
- schema: { "issues": [{ "line": int, "severity": "error"|"warning", "message": string }] }
- max_items: 10

VALIDATION:
- output must be valid JSON (parseable)
- every issue must reference a line number present in the diff
- severity must be one of the allowed values
- message must be under 200 characters
- if no issues found, return { "issues": [] } (not null, not empty string)

ERROR:
- if output fails validation, retry once with: "Your output failed validation: {error}. Fix and return only the corrected JSON."
- if retry also fails, return the raw output tagged as UNVALIDATED

Why This Matters

With validation rules, you can:

  1. Auto-retry on structural failures (JSON parse errors, missing fields)
  2. Log quality metrics (what % of outputs pass validation first-try?)
  3. A/B test prompts with a consistent scoring mechanism
  4. Chain prompts safely — downstream steps can trust the upstream output

A Real Example: Code Review Contract

Here’s a complete contract I use for automated PR reviews:

# Code Review Contract v2.1

## INPUT
- diff: unified diff of the PR
- context: file-level summary (max 500 tokens)
- focus_areas: list of strings (e.g., ["security", "performance"])

## OUTPUT
- format: Markdown
- sections: Summary (2-3 sentences), Issues (bulleted list), Verdict (APPROVE / REQUEST_CHANGES)
- max_length: 800 words

## VALIDATION
- must contain exactly 3 sections with the headers above
- Issues section: each bullet must start with [ERROR], [WARNING], or [SUGGESTION]
- Verdict must be one of the two allowed values
- Summary must not exceed 3 sentences
- No line references to files not in the diff

## ERROR
- missing section → retry with "You're missing the {section} section"
- invalid verdict → retry with "Verdict must be APPROVE or REQUEST_CHANGES"
- 2 consecutive failures → flag for human review

The Validation Checklist Template

Copy this and adapt it for any prompt:

## VALIDATION
- [ ] Output format matches spec (JSON / Markdown / plain text)
- [ ] All required fields present
- [ ] Field types correct (strings are strings, numbers are numbers)
- [ ] Values within allowed ranges / enums
- [ ] Length constraints met (min/max words, items, characters)
- [ ] No hallucinated references (files, URLs, variables that don't exist in input)
- [ ] Consistent with input (doesn't contradict the given context)

Wiring It Up in Code

If you’re calling an LLM from code, validation is just a function:

def validate_review(output: dict) -> list[str]:
    errors = []
    required = ["Summary", "Issues", "Verdict"]
    for section in required:
        if section not in output:
            errors.append(f"Missing section: {section}")
    if output.get("Verdict") not in ("APPROVE", "REQUEST_CHANGES"):
        errors.append(f"Invalid verdict: {output.get('Verdict')}")
    return errors

# Usage
result = call_llm(prompt)
issues = validate_review(result)
if issues:
    result = call_llm(f"Fix these validation errors: {issues}nnOriginal output: {result}")

Simple. No framework needed.

When to Skip Validation

Not everything needs a contract. If you’re brainstorming, exploring, or doing creative work, contracts add friction without value.

Use contracts when:

  • Output feeds into another system
  • You’re running the prompt more than once
  • Incorrect output has a real cost (time, money, bugs)

Skip contracts when:

  • You’re thinking out loud
  • The output is for your eyes only
  • You’ll edit it heavily anyway

Key Takeaway

Prompt contracts v1 told you to define the spec. v2 tells you to enforce it.

The validation block turns your prompt from a wish into a testable requirement. That’s the difference between “AI-assisted” and “AI-reliable.”

Grab the template above and try it on your most-used prompt. Share what breaks — I’m collecting failure modes for a follow-up post.

Build for Consumability, The Provider to Consumer Model That Makes AWS Scale.

AWS Provider to Consumer Model

Most platform teams think their job is to build infrastructure. They’re wrong. Their job is to build infrastructure that other teams can consume without thinking about it.

The difference matters more than most organisations realise. A platform team that builds a beautifully architected AWS landing zone but makes it painful to consume has built a bottleneck, not a platform. And in a large enterprise (where dozens of product teams need to ship features against real deadlines) bottlenecks don’t just slow you down, they breed shadow IT, workaround architectures, and the kind of ungoverned sprawl that keeps security teams awake at night.

The Provider to Consumer model fixes this. It’s a shared responsibility framework that gives the platform team (the Provider) clear ownership of foundational infrastructure, while giving product teams (the Consumers) a fast, governed path to build and deploy. Neither side operates in isolation. Both sides have accountabilities, and the interfaces between them are deliberate, opinionated, and battle-tested.

Here’s how it works, and why it’s the pattern that separates enterprises that scale confidently from those that scale chaotically.

The Problem With “We’ll Build It For You”

In too many enterprises, the cloud platform team operates as a service desk. Product teams raise a ticket > The platform team provisions a VPC > Another ticket > An IAM role gets created > Another ticket > A CI/CD pipeline appears three weeks later.

This model doesn’t scale. It can’t. Every request becomes a queue item. Every queue item becomes a lead time. Every lead time becomes a frustrated product manager asking why it takes six weeks to get a development environment when they could spin one up on a personal AWS account in twenty minutes.

The instinct to centralise everything and hand it out on request comes from good intentions (governance, security, cost control). But the execution model is fundamentally wrong. You’re not providing a platform. You’re providing a gatekeeping function that happens to run on AWS.

The Provider to Consumer model inverts this. Instead of building things for consumers, you build things that consumers can use themselves (within boundaries you’ve already defined).

What the Provider Actually Owns

The Provider is the cloud platform team. Their job is to provision and maintain the foundational layers that every product team needs (and to do it in a way that’s repeatable, governed, and fast).

There are four core responsibilities.

Account Provisioning with CIS Baseline. Every new workload gets its own AWS account pattern, provisioned through automation with a CIS security (or other supported frameworks) baseline already applied. The consumer doesn’t request a hardened account and wait. The account arrives hardened. Security isn’t a follow-up step, it’s baked into the provisioning pipeline. AWS Control Tower watches across the entire organisation, enforcing guardrails that the consumer doesn’t need to think about because they can’t violate them.

VPC Design and Provisioning. The Provider owns the network architecture. Every account receives a VPC built from a repeatable reference architecture, consistent CIDR allocation, subnet tiering, inspection architecture, and connectivity back to shared services. The consumer doesn’t design their own network. They don’t choose their own CIDR ranges. They don’t decide whether they need a NAT gateway. The VPC arrives ready, and it’s the same shape every time. This isn’t about limiting creativity, it’s about eliminating an entire category of networking incidents that happen when teams roll their own.

CI/CD Pipeline and Repository Provisioning. The Provider provisions a GitHub repository with industrialised GitHub Actions workflows already wired in. The PR workflow (plan on pull request, apply on merge) is pre-configured and opinionated. The consumer doesn’t build their own pipeline from scratch. They don’t decide how Terraform gets executed. They inherit a workflow that’s been tested across dozens of teams and hundreds of deployments. It works. Use it.

Infrastructure as Code Modules. The Provider maintains a library of battle-tested Terraform (or Terragrunt/OpenTofu) modules for commodity services (compute, databases, storage, queues, load balancers), the building blocks that every product team needs. These modules encode the organisation’s opinions about how things should be built – tagging standards, encryption defaults, logging configuration, IAM boundaries. A consumer deploying an RDS instance through a Provider module gets all of that for free. A consumer building their own from raw Terraform resources gets none of it, and that creates a governance gap that someone will eventually have to close.

What the Consumer Actually Owns

The Consumer is the product team, developers and DevOps engineers building features, services, and applications on top of the platform.

Their accountabilities are different from the Provider’s, but they’re not lesser. The Consumer owns the application. The Provider owns the platform. The boundary between them is the interface contract, and both sides have obligations to honour it.

Building in a Safe, Governed Zone. The Consumer operates within the account, VPC, and pipeline that the Provider has established. They don’t need to understand how the VPC was designed (but they do need to place their resources in the correct subnets). The Provider exposes this through lookups – the Consumer queries for the right subnet by type (public, private, data/backend etc) and gets back the correct placement. The VPC configuration is locked. The lookup is available. This is the right trade-off.

Using the CI/CD Pipeline for All Infra Changes. No ClickOps, no console deployments – every infrastructure change goes through the PR workflow the Provider has established. Plan on pull request > review > approve > apply on merge > promote through environments. The Consumer uses this pipeline, they don’t modify it, bypass it, or build an alternative alongside it. The pipeline is the governance boundary, and respecting it is what makes the model work. If there are exceptions where console access is unavoidable, then it’s granted on a request/approval basis, it is also temporary.

Consuming Infra IaC Modules for Resource Provisioning. When the Consumer needs infrastructure, they reach for a Provider module first. Need an S3 bucket? There’s a module. Need an ECS service? There’s a module. Need an Aurora cluster? There’s a module. These modules aren’t suggestions, they’re the supported path. Building outside them is possible but comes with additional review requirements, because you’re stepping outside the governed envelope.

The Shared Responsibility in Practice

AWS Provider to Consumer Model

The diagram that captures this model cleanly shows an AWS account as a shared space with a clear dividing line. On the left: the Provider’s contributions. On the right: the Consumer’s usage of those contributions. In the middle: the interfaces that connect them.

At the account layer, the Provider provisions the account with the required security frameworks applied. The Consumer sees Control Tower guardrails watching over their environment. They build in a safe zone, not because they chose to be safe, but because the environment won’t let them be unsafe.

At the network layer, the Provider delivers a repeatable reference VPC architecture. The Consumer gets lookup access to find the correct subnet for their workload placement. The config is locked. The information is accessible. The Consumer can read the network topology but can’t rewrite it.

At the CI/CD layer, the Provider delivers an Infra GitHub Actions workflows. The Consumer uses them to create and promote through their PR-driven deployment workflow. Infrastructure changes flow through the same governed pipeline regardless of which team is making them.

At the IaC layer, the Provider delivers modules. The Consumer uses them. The module encodes the organisation’s standards. The Consumer supplies their workload-specific configuration. The result is infrastructure that’s both customised to the application and compliant with the organisation’s baseline.

This isn’t a hierarchy, it’s a contract. The Provider promises a fast, secure, consistent foundation. The Consumer promises to build on that foundation rather than around it.

Why “Built for Consumability” Is the Whole Point

Here’s the phrase that should be pinned above every platform team’s desk: Build for Consumability.

The value of a platform isn’t measured by how sophisticated the Provider’s architecture is. It’s measured by how quickly a Consumer can go from “I need an environment” to “I’m deploying code.” If that journey takes six weeks and twelve tickets, the architecture doesn’t matter. You’ve failed.

Fast request through to environment provisioning means a product team can go from approved request to fully provisioned, governed AWS account with VPC, pipeline, and IaC modules, ready to build, in minutes, not weeks (as long as the required information is provided to facilitate the process). This is what automation exists for. Not to make the platform team’s life easier (though it does), but to make the Consumer’s path frictionless.

Clear separation of accountabilities means nobody is confused about who owns what. The Provider doesn’t debug application code. The Consumer doesn’t redesign the VPC. When something goes wrong (and it will) the separation makes triage faster because the ownership boundaries are unambiguous.

Governed and opinionated do’s and don’ts include fail-safes meaning the platform has opinions, and those opinions are enforced. You can deploy an RDS instance through the module. You can’t deploy an unencrypted RDS instance because the module won’t let you. You can create a security group. You can’t open 0.0.0.0/0 to the internet because the guardrail catches it. These aren’t restrictions on productivity, they’re the reason the Consumer can move fast.

Industrialised VPC architecture means the network is solved. Every account gets the same proven design, with inspection, segmentation, and connectivity built in. The Consumer never has to become a networking expert to ship a feature.

Industrialised GitHub Actions means the infra deployment pipeline is solved. Every team gets the same PR workflow, the same plan-and-apply pattern, the same promotion gates. The Consumer never has to become an infra CI/CD expert to deploy safely.

Battle-tested IaC modules means the building blocks are solved. Every commodity service has a module that encodes security, tagging, logging, and best practices. The Consumer never has to become a Terraform expert to provision compliant infrastructure.

See the pattern? The Provider solves categories of problems once. The Consumer benefits from those solutions repeatedly. That’s consumability.

The Feedback Loop That Makes It Better

The Provider to Consumer model isn’t static. The best implementations include a feedback loop where Consumer teams report friction, request new modules, and flag gaps in the platform.

A Consumer team discovers they need a service that doesn’t have a module yet? That’s a signal to the Provider to build one. A module is too rigid for a legitimate use case? That’s a signal to add a configurable parameter. The PR workflow doesn’t handle a team’s branching strategy well? That’s a conversation worth having.

The Provider maintains the platform. The Consumer pressure-tests it. Over time, the platform gets better not because the Provider imagines what teams need, but because teams tell them, through usage patterns, through support requests, and through the occasional frustrated Slack message that starts with “why can’t I just…”

Those messages aren’t complaints. They’re product feedback. And the platform teams that treat them that way build platforms that people actually want to use.

What Happens When You Get This Wrong

If the Provider builds without thinking about consumability, you get a technically excellent platform that nobody uses. Teams route around it. They build their own VPCs in accounts they shouldn’t have access to. They set up their own pipelines because the official one is too slow or too rigid. Shadow infrastructure proliferates, and the governance that the platform was supposed to provide evaporates.

If the Consumer ignores the Provider’s interfaces and builds from scratch, you get inconsistency. Every team’s infrastructure looks different. Tagging standards vary. Encryption policies are applied ad hoc. When audit time comes (and it always comes) you’re answering questions about fifty different implementations instead of one.

If neither side respects the boundary, you get the worst of both worlds: a platform team drowning in bespoke requests and product teams waiting in queues that shouldn’t exist. This is the state most enterprises find themselves in before they adopt the model. It feels normal because everyone’s used to it. It isn’t normal. It’s just common.

Why This Matters Now

Enterprises are scaling their AWS & general cloud footprints faster than ever. Hundreds if not thousands of accounts. Thousands of developers. Hundreds of 3rd party suppliers. Dozens of product teams with different tech stacks, different timelines, and different risk tolerances.

You cannot govern this with ticket queues and manual provisioning. You cannot secure this by reviewing every pull request centrally. You cannot maintain consistency by writing standards documents that nobody reads.

You can govern it by building a platform that’s opinionated by default and consumable by design. You can secure it by encoding security into the modules and guardrails that teams use every day. You can maintain consistency by making the compliant path the easiest path.

The Provider to Consumer model isn’t just an operating model. It’s a force multiplier. Every investment the Provider makes in a module, a pipeline, or a guardrail pays dividends across every Consumer team that uses it. And when the platform is built for consumability, those Consumer teams don’t just tolerate the platform, they depend on it.

Build for consumability. Build the interfaces. Define the boundaries. And then let your product teams build, fast, safe, and at scale.