People keep asking me how I actually work with Claude Code now. Not the “does AI coding work” question — I’ve beaten that horse into glue on this blog already — but the boring, practical, how-does-my-loop-actually-work question. So here it is. The whole rig. And fair warning: what I’m about to describe is a chainsaw, and most of the industry is still lined up at the axe-throwing contest, spitting on their hands.
Everybody's admiring their axe technique. I already loaded the tree on the truck.
Caveat: this works for me, and something similar is working for parts of my Engineering team at my day job. I’m working on making that most of my Engineering team, but changing other humans takes time. This is how I am working. Today. It’s literally evolving weekly, so if it’s past August 2026 and you are reading this.. well then, these may not be the droids you’re looking for. Things change quickly!
I’ve been here for all of it. TRS-80 with 4K of RAM. My first Linux kernel before it hit 1.0. Every hype cycle, every silver bullet, every “this changes everything” that changed almost nothing. So when I say the loop I’m running now is genuinely different, that’s not the hype talking — I’ve got enough scar tissue to know the difference.
Here’s the thesis: the unit of work is no longer the keystroke. It’s the loop. I don’t write code anymore so much as I set up a disciplined, sandboxed loop and let it run — vision, requirements, design, test-first implementation, review, done. My job is the two ends of that loop: telling it what “good” means at the start, and judging whether it got there at the end. The middle runs on its own.
This is totally applicable to green-field new projects. Less so for legacy code - there’s different loops for that!
Let me lay out the parts.
The Loop, In One Breath #
Three pieces, and they stack:
A padded room— a container where the AI can do whatever it wants and the blast radius is onepodman rm
and the local folder I started it from. That’slocaldev. And the extra padding is git.A chainsaw— a Claude plugin full of engineering-discipline skills, the flagship being a full design-build-test-review cycle calledbuild-autonomous
. That’s thegherlein-claude-plugin.A tree— some actual thing I want built. Today’s tree is a little Go tool to boss around a smart plug. Because it’s simple.
The container is safety. The plugin is discipline. The task is Tuesday. Take away any one of them and I’ve got a worse version of what everyone else is doing. Together they’re a special forces assault on what I can get done.
The Padded Room: localdev #
I let Claude Code run with --dangerously-skip-permissions
. No “mother may I” for every ls
. No clicking approve fifty times to run a test suite.
Before that makes anybody spit out their coffee: I keep it in a jail. A container jail.
I wrote about localdev in detail already, so I won’t re-run the whole thing, but the shape matters for the loop:
— my working directory, read-write. This is the tree./<project>/
— my host/claude/
~/.claude
, so my globalCLAUDE.md
and my installed plugins come along for the ride.— reference material, mounted/external/<name>/
read-only. Claude can read it, learn from it, and physically cannot scribble on it.
That last one is the unsung hero. When I’m porting or referencing code I don’t want touched, it goes in /external
read-only. I’ve been burned by “helpful” AI edits to source I was only trying to read. Read-only mounts end that whole category of pain.
I am actively thinking about better ways to expose read-only material. Knowledge is the new frontier here.
Dangerous mode plus a container equals a fast loop with a small blast radius.
And I’ll say the unglamorous thing again, because it’s the most important thing: git is the real safety net. The container protects my host. Git protects my work. I commit early, commit often, commit like a paranoid submariner logging every valve lineup. When Claude decides at 2am that my error handling “really should” be six microservices, git reset --hard
is the sound of me sleeping soundly.
The Chainsaw: build-autonomous #
Here’s the part that turns “AI writes some code” into “AI ships a thing the way I’d ship a thing.”
I packaged my engineering discipline — the stuff I’d normally carry in my head and nag a junior about — into a set of Claude Code skills. Skills are little instruction packets that either auto-trigger when my request matches, or that I invoke by name for the heavy workflows. Planning, code review, test-first development, spec-driven design, Go performance, git hygiene, systematic debugging. Roughly forty of them.
The flagship is build-autonomous
. It is exactly what it sounds like, with one crucial catch: it is not actually hands-off at the start, and that’s the whole point.
It’s a loop with two human-gated doors at the front and a long autonomous corridor after them:
Door 1 — Vision (I own this). It opens by brainstorming, one question at a time — purpose, users, goals, non-goals, constraints, success criteria — and writes all of it into VISION.md
. I usually seed that file with a few lines of basic desire. It does not advance until I literally say “vision looks good.” I can’t skip this by dumping a paragraph of requirements on it. It’ll still make me talk.
Door 2 — Requirements (I own this too). Using the vision, it writes REQUIREMENTS.md
— functional requirements, numbered constraints (C-001
, C-002
…), invariants, the non-functional stuff, and an explicit out-of-scope list. Again: it loops until I say requirements are done, then commits both files.
Then the corridor. From here it runs on its own:
Design→docs/DESIGN.md
(architecture, interfaces, state model).Test plan→docs/TEST-PLAN.md
, including a sub-30-second smoke suite.Project setup→ git init if needed, a sane.gitignore
.Phased, test-first implementation— a failing testbeforethe code, every time, no exceptions. One phase at a time. All tests green before the next phase starts. A failing test triggerssystematic debugging, not blind retrying.Integration validation— the whole suite, the build, the linters.** A three-way review gate**— spec compliance, design/architecture, and security, each written to its own file.** Remediation**— fix all the critical and high findings, document the deferred ones with a reason.** A final verification gate**, thenREADME.md
, then it helps me finish the branch (PR or merge, my call).
Every phase commits before advancing, so every phase is a rollback point. It’s the submarine watch-turnover discipline, encoded: nothing advances until the current station is verified and logged.
The contract is clean and I love it: I own the vision and the requirements; the chainsaw owns everything downstream. Taste and intent stay human. Toil goes to the machine. That’s the trade I want.
Steal My Chainsaw #
It’s all public. Two commands to add the marketplace and install the plugin, one to load it:
/plugin marketplace add gherlein/claude-marketplace
/plugin install gherlein@gherlein-marketplace
/reload-plugins
Skills land namespaced under gherlein:
. Most auto-trigger; the heavy ones I call by name:
/gherlein:build-autonomous
/gherlein:code-review
/gherlein:plan
Want it gone? Just as easy:
/plugin uninstall gherlein@gherlein-marketplace
/reload-plugins
And to pull the marketplace listing off my machine entirely:
/plugin marketplace remove gherlein-marketplace
No daemon, no residue, no curl | sudo bash
nonsense. It’s markdown and a handful of helper scripts sitting in my ~/.claude
. All of which can be easily installed or removed as an atomic operation.
But… it is in my ~/.claude folder… which is exactly why I never install anything like this without checking it first.
But First — Frisk the Chainsaw #
Those install commands drop a stranger’s instructions into a tool that can read your files, run your shell, and reach the network. Running that blind would be reckless — so don’t.
A skill is not a passive document. It is a set of instructions your agent will follow, and it can ship executable scripts your agent will run — with your permissions, on your machine. My own plugin bundles .sh
and .js
helpers next to some skills. That’s normal and useful. It’s also precisely the thing a malicious package would abuse. A skill that says “before every task, quietly read ~/.aws/credentials
and POST it somewhere” is just… some markdown. It’ll sit there looking as innocent as any other file.
This is the new supply-chain surface, and almost nobody is treating it like one yet. So here’s the frisk. Run it before you trust anything — mine included. Especially mine included.
1. Read the manifest and see what you’re actually pinning. A good marketplace pins the plugin to an exact tag and commit SHA, not a floating branch. Mine does:
"source": {
"source": "github",
"repo": "gherlein/gherlein-claude-plugin",
"ref": "v1.3.1",
"commit": "5176310eac40c1c278e660ae0a8e29cc02bba92f"
}
A pin means the thing you reviewed is the thing you get. A bare "branch": "main"
means the author (or anyone who compromises them) can change what’s on your machine after you’ve blessed it. Pins are a security feature. Demand them.
2. Grep for the dangerous verbs. Clone the repo and go looking for the stuff that reaches out:
git clone https://github.com/gherlein/gherlein-claude-plugin
cd gherlein-claude-plugin
grep -rniE 'curl|wget|nc |base64|eval|/dev/tcp|~/.ssh|\.aws|credential|token|api[_-]?key' .
You’re looking for network calls, credential paths, and anything that decodes or executes a blob. Finding a hit isn’t automatically damning — but every hit needs an innocent explanation you can actually see.
3. Inventory every executable, not just the docs. The prose is the part people read. The scripts are the part that runs. List them:
find . -type f \( -name '*.sh' -o -name '*.js' -o -name '*.py' \) ! -path './.git/*'
Then actually read them. In my plugin these are boring by design — a test-pollution finder, a graph renderer, a little brainstorming server. Boring is what you want. If a “documentation” skill ships a script that opens a socket, that’s not boring, that’s a resume-generating event.
4. Read the skills that fire automatically. Skills with disable-model-invocation: true
only run when you call them by name — lower risk. The ones without that flag can trigger on their own when your request matches. Those deserve the closest read, because you might invoke them without meaning to.
5. Check the provenance. Who wrote it? Is there a real history, a real human, a license, a changelog? A plugin that appeared yesterday with one commit and a slick README is a different risk than one with months of visible history. (And yes, I know, “months of history” is exactly what a patient attacker builds. Provenance is a signal, not a proof. Layer it with the greps.)
6. Run it in the jail first. Full circle to localdev. The first time you run any new skill package, do it in the padded room, on a throwaway project, with nothing sensitive mounted and no real credentials in the environment. Watch what it does. Then decide whether it earns a spot in your real workflow.
I’ll be blunt, because this is the kind of thing I’d say over coffee and mean it: the productivity of this whole approach depends on running an agent with broad permissions. That is exactly what makes an unreviewed skill package a genuinely bad idea. The chainsaw doesn’t care which way you point it. Frisk it first. Every time.
The same power that fells the tree cuts the leg. Point it carefully.
Now Let’s Cut Down a Tree #
Enough theory. Let me actually run the loop on something small and real — the chainsaw idling, revving, and cutting.
The tree: I’ve got a Shelly Plug US Gen4 on my bench — a Wi-Fi smart plug with real power metering. I want a dead-simple Go command-line tool to turn it on and off and read its live volts, amps, and watts. That’s it. No dashboard, no daemon, no cloud. A single binary I can drop on a Raspberry Pi or my laptop.
Is build-autonomous
overkill for a tool this small? Completely. That’s the joke. We’re bringing a chainsaw to trim a bonsai. But the discipline the loop applies even to something this trivial is the whole reason I trust it on the big stuff.
Door 1 and Door 2: the part I actually do
First I spin up the padded room from the new project folder on my host:
mkdir plugctl && cd plugctl
localdev # boots the container, mounts this folder at /plugctl
Now I’m inside the jail. I start Claude in dangerous mode and hand it the job:
cd /plugctl
clauded # claude --dangerously-skip-permissions
/gherlein:build-autonomous a Go CLI to control a Shelly Plug US Gen4:
turn it on and off, and read live voltage, current, and power.
It does not start coding. It starts asking. One question at a time, and I answer into what becomes VISION.md
:
Purpose? A tiny operator tool for one smart plug on the local network.Users? Me, from a terminal or a shell script. Exit codes matter; it might get scripted.How does it talk to the plug? Shelly Gen4 speaks the Gen2+ RPC API over plain HTTP. No cloud account, no MQTT broker required. Good — local only.Config? A--host
flag, falling back to aPLUG_HOST
environment variable (and a--timeout
with aPLUG_TIMEOUT
fallback). No config file for v1.Non-goals? No multi-plug management, no scheduling, no discovery, no TLS gymnastics. On, off, read. Done.Success criteria?plugctl on
flips the relay.plugctl status
prints volts/amps/watts. Bad host or unreachable plug exits non-zero with a clear message.
I say “vision looks good.” Then it writes REQUIREMENTS.md
and I watch constraints show up with IDs I can actually trace:
C-002:NEVER hang indefinitely on an unreachable or slow device — every network call is bounded by the resolved timeout.C-003:NEVER write result data to stderr or error/diagnostic text to stdout — results to stdout, diagnostics to stderr, so output is safe to pipe.C-004:NEVER treat a JSON-RPC error response or an HTTP non-2xx as success — fail loud, exit non-zero.
Plus a real exit-code contract (FR-4
) that scripts can branch on: 0
success, 2
usage error, 3
network/unreachable, 4
device/RPC error.
I say “requirements are done.” Both files get committed. Now I take my hands off the wheel.
The corridor: the part it does
It designs (docs/DESIGN.md
: an internal/shelly
client package, a root package main
CLI, three subcommands). It writes a test plan. Notice what it doesn’t do here: this tool consumes an HTTP API, it doesn’t expose one, so the loop correctly skips the external API-canary step. Small detail, but it’s the kind of thing that tells me the discipline is real and not cargo-culted.
Then it goes test-first. A failing test for the client parsing a known Shelly status payload, then the parser. A failing test for the off
command issuing the right RPC against an in-memory httptest
server, then the command. Red, green, next.
Here’s the heart of what came out — the internal/shelly
client, trimmed to the good parts. The Shelly Gen4 speaks the Gen2+ RPC API as HTTP POST to /rpc with a JSON-RPC envelope as the body —
notGET-with-query-params, which is the thing I’d have guessed wrong if I’d written it by hand:
// Package shelly is a minimal client for the Shelly Gen2+ RPC API
// as spoken by the Shelly Plug US Gen4 over local HTTP.
package shelly
const requestID = 1
// maxResponseBytes caps the body so a hostile or broken device can't force a
// huge allocation on a small SBC (CWE-400).
const maxResponseBytes = 1 << 20 // 1 MiB
// Client talks to one Shelly Gen2+ device over HTTP. Construct it with NewClient.
type Client struct {
baseURL *url.URL
http *http.Client
}
// NewClient normalizes host into a base URL and builds a client whose HTTP
// timeout bounds every call (C-002: never hang forever).
func NewClient(host string, timeout time.Duration) (*Client, error) {
base, err := normalizeHost(host) // parsed with net/url, never string-concatenated
if err != nil {
return nil, err
}
return &Client{baseURL: base, http: &http.Client{Timeout: timeout}}, nil
}
// SwitchStatus is the subset of Switch.GetStatus we care about.
// Field names track the Shelly Gen2+ RPC schema exactly.
type SwitchStatus struct {
Output bool `json:"output"`
APower float64 `json:"apower"` // active power, watts
Voltage float64 `json:"voltage"` // volts
Current float64 `json:"current"` // amps
}
// Set invokes Switch.Set{id,on} and returns the device's prior state (was_on).
func (c *Client) Set(ctx context.Context, id int, on bool) (bool, error) {
var res setResult
if err := c.call(ctx, "Switch.Set", setParams{ID: id, On: on}, &res); err != nil {
return false, err
}
return res.WasOn, nil
}
// GetStatus invokes Switch.GetStatus{id} and returns parsed telemetry.
func (c *Client) GetStatus(ctx context.Context, id int) (*SwitchStatus, error) {
var st SwitchStatus
if err := c.call(ctx, "Switch.GetStatus", statusParams{ID: id}, &st); err != nil {
return nil, err
}
return &st, nil
}
// call performs one JSON-RPC round trip: POST <baseURL>/rpc with the request
// envelope as the body, then decode result into out. Failures surface as typed
// errors (*NetworkError, *RPCError, *ProtocolError) the CLI maps to exit codes.
func (c *Client) call(ctx context.Context, method string, params, out any) error {
body, err := json.Marshal(rpcRequest{ID: requestID, Method: method, Params: params})
if err != nil {
return &ProtocolError{Detail: "encoding request: " + err.Error()}
}
endpoint := c.baseURL.JoinPath("rpc").String()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return &NetworkError{Op: "build request", Err: err}
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return &NetworkError{Op: "http request", Err: err}
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return &NetworkError{Op: "read response", Err: err}
}
var env rpcResponse
if err := json.Unmarshal(data, &env); err != nil {
return &ProtocolError{Detail: "invalid JSON response: " + err.Error()}
}
if env.Error != nil { // C-004: a device error is never success
return &RPCError{Code: env.Error.Code, Message: env.Error.Message}
}
return json.Unmarshal(env.Result, out)
}
Notice the C-002
and C-004
comments right there in the code, tracing straight back to the requirements. That’s the spec-driven
skill doing its job: constraints get IDs, and the IDs show up in the vision, the requirements, the design doc, and the code. Six months from now, grep C-004
tells me why the tool refuses to treat a device error envelope as a success. That’s not decoration. That’s encapsulated intent.
The CLI on top is boring in the best way. The one design decision worth pointing at: main
is a two-line shim over a run
that takes its I/O and returns an exit code, so the whole program — flag parsing, error mapping, exit codes — is table-testable without spawning a process or mocking anything:
// exit-code contract (FR-4): scripts may branch on these.
const (
exitOK = 0
exitUsage = 2
exitNetwork = 3
exitDevice = 4
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
// run is the whole program with I/O and exit code injected. It never calls os.Exit.
func run(args []string, stdout, stderr io.Writer) int {
// ...parse command, resolve --host / $PLUG_HOST, --timeout, --id...
client, err := shelly.NewClient(host, timeout)
if err != nil {
fmt.Fprintf(stderr, "plugctl: %v\n", err)
return exitUsage
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
switch command {
case "on", "off":
on := command == "on"
wasOn, err := client.Set(ctx, id, on)
if err != nil {
fmt.Fprintf(stderr, "plugctl: turning plug %s failed (host %s): %v\n", command, host, err)
return classifyError(err) // *NetworkError -> 3, device errors -> 4
}
fmt.Fprintf(stdout, "switch %d: %s (was %s)\n", id, stateWord(on), stateWord(wasOn))
return exitOK
case "status":
st, err := client.GetStatus(ctx, id)
if err != nil {
fmt.Fprintf(stderr, "plugctl: reading status failed (host %s): %v\n", host, err)
return classifyError(err)
}
writeStatus(stdout, id, st)
return exitOK
}
return exitUsage // unreachable: command already validated
}
classifyError
is a pure function of the client’s typed error — *NetworkError
maps to exit 3
, *RPCError
/*ProtocolError
to 4
, and anything unrecognized fails closed to 4
rather than sneaking out as success (OWASP A10). Using it is exactly as dumb as I wanted:
export PLUG_HOST=192.168.1.42
plugctl on
plugctl status
plugctl status --json | jq .power # same fields, one JSON object, for scripts
plugctl off
Then the loop finishes the job the way I would: runs the whole test suite, runs the linters, does its three-way review (spec, design, security — even for this, it checks that I’m not building URLs from unvalidated input in a dumb way), writes a real README.md
, and offers to open a PR.
Total human effort: two doors’ worth of conversation. Everything else — the design doc, the test-first client, the traceable constraints, the review, the README — happened while I refilled my coffee.
That’s the chainsaw.
What Can Go Wrong #
Because it’s me, and because I don’t sell silver bullets:
Garbage in, garbage out — at the vision gate. The loop is only as good as the two doors I own. Wave my hands at requirements and I get a confident, well-tested implementation of the wrong thing. The discipline movedupthe stack, to intent. It didn’t disappear.It will occasionally over-engineer. Bring a chainsaw to a bonsai and sometimes I get a bonsai-processing subsystem. So I read the design doc at the gate. I push back.The security surface is real. Everything I said about frisking skill packages applies every time I pull in someone else’s — and it’s exactly the scrutiny I’d want applied to mine. This is a new supply chain and it is not yet being treated like one.Git is still my parachute. The container bounds the blast radius; git bounds thetimeradius. I commit constantly.
Conclusion #
The loop is the unit of work now. A padded room so the agent can move fast without endangering my system. A plugin full of encoded discipline so “fast” doesn’t mean “sloppy.” And two human-owned gates at the front so my taste and intent still drive the whole thing.
Is it a chainsaw at an axe-throwing contest? Yes. It’s faintly ridiculous, people look at me funny, and the safety briefing is not optional. But the tree’s already down and sectioned while everyone else is still admiring their swing.
The plugin and the container are both public if the same rig sounds useful. But frisk anything like this before trusting it — I mean that. Read the scripts, check the pins, run it in the jail first. Then let it rip.
And if it helps somebody? Drop me a note on LinkedIn. Pay it forward.
One Last Thing #
You cannot learn to swim by reading a book about swimming. You can memorize the stroke mechanics, the breathing pattern, the physics of buoyancy — and you will still sink like a stone the first time you’re in the deep end. The only way to learn is to get wet, flail around, swallow some water, and figure it out with your own arms and lungs. Reading a repo is exactly the same. You can clone this thing, read every line, nod along at the traceable constraints and the typed errors — and you will have learned nothing about the loop until you spin up your own padded room, seed your own VISION.md
, and let a chainsaw run at your own tree while you watch. The repo is the destination. The swimming is the point.
The only way out is through. Get in the water.
So: the full plugctl
— everything the loop generated to build and test all of this, the vision, the requirements, the design doc, the test-first client, the reviews, the README — is public at github.com/emergingrobotics/plugctl. Read it, sure. But then go get wet.