# Unhobbling: Goals, Not Steps

> You take a model that can plan, read a whole repo, and run your tests, and you cram it into a single-line edit. The horse can run — you tied its legs and you're proud it can still hop. Here's how to cut the rope.
>
> Published: 2026.07.28 · 14 min · AI, CLAUDE CODE, AGENTS, GUIDE
> Canonical: https://cyperx.dev/blog/claude-code-unhobbling

---
You've been running Claude Code for a month. You've got a workflow. First you tell it exactly which file to open. Then you tell it which function to edit. Then you paste the three lines you already wrote in your head. It types them out. You approve. Next step.

Congrats. You've built a very expensive autocomplete.

There's a word for this, and I stole it from an interview Boris Cherny did with the person who created Claude Code: **hobbling**. You take a model that can plan, read a whole repo, run your test suite, and iterate against real output — and you cram it into a box the size of a single line edit. The horse can run. You've tied its legs together and you're proud it can still hop.

This post is about cutting the rope.

![A developer breaking free of handcuffs and stepping toward a full terminal](/images/blog/cc-unhobbling.webp)

## The overhang nobody talks about

There's a gap between what the model *can* do and what your setup *lets* it do. Call it product overhang. The capability shipped months ago. Your habits didn't update.

Here's the tell. When Claude Code does something that surprises you — refactors across four files you didn't mention, notices a test you forgot, catches an edge case — your gut reaction isn't "great." It's a little flinch. *Wait, I didn't tell it to do that.* That flinch is the overhang talking. You've been running the model at 20% because 20% is all your prompts ask for.

Per the interview, the creator's whole framing is that each model generation gets *less* hobbled — the tooling stops being the bottleneck and the model's own judgment takes over. The forward-looking version of this (again, per the interview, not settled fact) is that something like Opus 5 needs even less scaffolding than today, not more. If that's the direction, then every elaborate step-by-step harness you build today is scaffolding you'll rip out in six months. You're optimizing the wrong layer.

The fix isn't a better prompt template. It's a different *shape* of instruction. Stop scripting steps. Start scoping goals.

## Steps vs goals

![Animated autonomy ladder from autocomplete to a verified scheduled agent routine](/images/blog/cc-unhobbling-autonomy-ladder.svg)

A step-by-step prompt encodes your plan. A goal-based prompt encodes your intent and lets the model plan. The difference sounds small. It isn't.

When you write steps, every step is a place you can be wrong. You're injecting your priors as hard requirements — and half your priors are guesses. You *think* the bug is in the validation layer, so you tell it to fix the validation layer. If you're wrong, the model dutifully fixes the wrong thing, because you didn't ask it to find the bug. You asked it to edit a file.

Let me show you three.

**Bad — the micromanaged rename:**

```
Open src/auth/session.ts. Find the function called checkSession.
Rename it to validateSession. Then open src/auth/index.ts and
update the export. Then search for checkSession in the api folder
and update the two call sites there.
```

You've done the grep in your head and you might be wrong. Maybe there are four call sites. Maybe one's in a test. Maybe `checkSession` is also referenced in a string somewhere for logging. You've turned a mechanical task the model is *great* at into a checklist where your incomplete search is the spec.

**Good:**

```
Rename checkSession to validateSession across the codebase.
Done when: no references to the old name remain, types pass,
and the existing auth tests still pass. Run the tests to confirm.
```

Now the model owns the search *and* the verification. It'll find the call site you forgot. This is the whole trick in one example: you stopped being the grep.

**Bad — the invented plan:**

```
Add caching to getUserProfile. Use a Map at module scope, key by
userId, and clear it on a 5-minute setInterval.
```

You designed the cache. In a prompt. In thirty seconds. Module-scope `Map` with a `setInterval` is a memory leak with extra steps, and you just made it the requirement.

**Good:**

```
getUserProfile hits the DB on every call and it's showing up as a
hotspot. Add caching. Match whatever caching pattern the codebase
already uses if there is one. Done when: repeat calls within the
TTL don't re-query, the cache invalidates on profile update, and
there's a test proving both.
```

Notice "match whatever pattern already exists." You probably have a caching util already. You forgot. The model won't, because you told it to go look instead of telling it what to type.

**Bad — the read-only leash:**

```
Look at the failing test in checkout.test.ts and tell me what you
think is wrong. Don't change anything yet.
```

This is hobbling dressed up as caution. You're going to read its diagnosis, agree, and then type "ok now fix it" — a full round trip to do the thing it could've just done. You've added a human approval gate to a task with an objective pass/fail signal built right in. The test *is* the reviewer.

**Good:**

```
checkout.test.ts is failing. Find the cause and fix it. Don't
change the test's assertions — the test describes the intended
behavior; the code is what's wrong. Show me the diff when the
test's green.
```

The exit signal is unambiguous. The guardrail ("don't change the assertions") stops the classic cheat where it makes the test pass by lobotomizing the test. And you get one diff to review instead of a conversation.

The pattern across all three: **you removed yourself from the loop where you added no value and stayed in the loop where you do** — reviewing the final diff.

## Exit criteria are the actual skill

![Glitch-style matrix contrasting hobbled implementation instructions with unhobbled scope, constraints and proof](/images/blog/cc-unhobbling-scope-matrix.svg)

Goal-based prompting lives or dies on the "done when" clause. Vague goal, vague result. This is where the real work moved to, and it's a better use of your brain than deciding which file to open.

A good definition of done has three parts:

1. **A verifiable end state.** Not "make it better." Something the model can check itself: tests pass, this endpoint returns 200, the type-checker's clean, the build succeeds, `curl localhost:3000/health` returns ok.
2. **Guardrails — the lines it can't cross.** "Don't touch the auth service." "Don't add new dependencies." "Don't modify the public API of this module." These are cheap to write and save you from the 2% of runs that go somewhere ugly.
3. **A verification method it can actually run.** If done means "tests pass," the model needs to be able to *run the tests*. Which brings us to the part everyone skips.

```
Goal: add rate limiting to the public API routes.

Done when:
- Each IP is capped at 100 req/min on /api/* routes
- Exceeding it returns 429 with a Retry-After header
- There's a test covering both the allowed and blocked cases
- npm test passes

Guardrails:
- Don't touch the auth middleware
- Don't add a new dependency if an existing one covers it
- Keep it in the existing middleware layer, don't restructure routing
```

That's a spec a model can chew on autonomously for ten minutes and hand you something real. And every line of it is *outcome*, not *keystroke*.

## Give it real tools or stop complaining

Here's the thing that makes people saddest to hear: if Claude Code asks permission before every single command, that's not the model being timid. That's your config. You never gave it the keys, so it knocks every time, and you concluded it can't work autonomously. You built the cage and blamed the bird.

`.claude/settings.json` has a permissions block. Allow and deny patterns. Set it once, per project, and the "can I run npm test?" prompts stop.

```json
{
  "permissions": {
    "allow": [
      "Bash(npm test)",
      "Bash(npm run test:*)",
      "Bash(npm run lint)",
      "Bash(npm run build)",
      "Bash(git status)",
      "Bash(git diff:*)",
      "Bash(git log:*)",
      "Edit(src/**)",
      "Write(src/**)"
    ],
    "deny": [
      "Bash(git push:*)",
      "Bash(rm -rf:*)",
      "Read(./.env)",
      "Read(./secrets/**)",
      "Edit(.github/workflows/**)"
    ]
  }
}
```

Read that as a contract. It can run your tests, lint, build, and read git history without asking. It can edit and write inside `src`. It *cannot* push, nuke directories, read your `.env`, or touch CI config — and those denies win over any allow, so you can be generous up top without losing sleep. The deny list is where your paranoia goes to be useful instead of blocking every keystroke.

Commit this file. It's project config, not personal. Your teammates get the same guardrails, and the model behaves consistently instead of depending on who typed "yes" how many times.

Then there are **permission modes**, which control the default posture for a session:

- **default** — asks before edits and commands it doesn't already have permission for. Fine for poking at unfamiliar code.
- **acceptEdits** — auto-accepts file edits so it can move through a multi-file change without a prompt per file. This is the mode for "I scoped a clear goal, go build it." Pair it with a sane deny list and you're not being reckless, you're being unblocked.
- **plan** — read-only. It investigates and hands you a plan, changes nothing. This is the *right* use of a read-only leash: reconnaissance on a scary codebase before you let it act. The difference between plan mode and the read-only prompt I mocked earlier is that plan mode is a deliberate recon step, not your permanent operating mode.

The move: recon in plan mode if the code's unfamiliar, then flip to acceptEdits with a tight deny list and a clear goal. Match the leash length to how much you trust the blast radius, not to how nervous you feel.

## Context via MCP, not a wall of prose

The other way people hobble Claude is by feeding it context as a giant paste. "Here's our schema" followed by 400 lines of SQL. "Here's our API" followed by a dump of the OpenAPI spec. It works, sort of, until the schema changes and your paste is a lie.

If the model needs live context — your database schema, your issue tracker, your internal docs — wire it in instead of pasting it. That's what MCP is for. You register a server once:

```bash
claude mcp add postgres -- npx -y @modelcontextprotocol/server-postgres \
  "postgresql://localhost/myapp"
```

Now Claude can query the actual schema when it needs it, at the moment it needs it, instead of trusting a snapshot you pasted last Tuesday. When you scope a goal like "add a migration for soft-deletes on the orders table," it can go *look* at the real orders table instead of asking you to describe it.

The rule of thumb: **paste for things that are stable and small, wire for things that are live or large.** A one-off constraint ("we use tabs, not spaces") is a paste — or better, a line in CLAUDE.md. Your production schema is not a paste. Your ticket queue is not a paste. Anything that drifts wants a connection, not a copy, because a stale copy is worse than no copy — it's confidently wrong.

## Delegation: hand off a whole goal

Once you're thinking in goals, subagents stop being scary. Claude Code can spin up a subagent via the Task tool and hand it a scoped goal to run in its own context window. You define reusable ones in `.claude/agents/`.

```markdown
---
name: test-writer
description: Writes tests for a given module. Use when new code
  needs coverage. Runs the suite to confirm they pass.
tools: Read, Edit, Write, Bash
---

You write tests for the module you're pointed at. Match the
existing test framework and file conventions — look first, don't
assume. Cover the happy path plus edge cases. Run the suite and
confirm your tests pass before reporting back. Do not modify
source files to make tests pass; if the code looks buggy, say so.
```

Now the main session can delegate: "write tests for the new rate-limiter using the test-writer agent." The subagent burns through file reads and test runs in *its* context, and your main thread stays clean, getting back a summary instead of a wall of tool output. That last part matters more than it sounds — the reason to delegate isn't just parallelism, it's keeping the noise out of the context you're actually steering.

The mental shift: a subagent is a coworker you handed a ticket to, not a macro you're stepping through. You wrote a good ticket (goal + done criteria + guardrails), you handed it off, you review what comes back. Same skill as before, one level up.

## The senior-engineer trap

Here's the part that stings, and it's aimed at the good engineers specifically.

The better you are, the worse you are at this. Experience taught you that vague instructions produce garbage, that juniors need explicit steps, that specifying everything upfront prevents rework. All true — for humans, historically. So your instinct, honed over years, is to *control*. Break it down. Specify each step. Leave nothing to interpretation.

That instinct is now the bug.

You're treating a system that can hold your whole repo in its head like a junior who needs hand-holding. You're spending your senior judgment on *which file to open* — the cheapest possible decision — instead of on *what done looks like and what it must not break*, which is the expensive, valuable one. The exact skill that made you senior, applied at the wrong altitude, becomes the thing hobbling your tools.

Self-check, and it's a real one. Look at your last five Claude Code prompts. Count how many told it **which file to edit or which function to change** versus how many described **an outcome and how you'd know it's done**. If it's mostly the former, you're not using Claude Code. You're using a fuzzy text editor with a chat window and a monthly bill.

The unlearning is uncomfortable because it feels like losing control. It's not. You're moving your control from the steps (where you add noise) to the boundaries (where you add safety). Tighter guardrails, looser leash.

## Worked example: the hobbled way vs the unhobbled way

Real request: *add pagination to the products list endpoint.*

**The hobbled way.** You open `products.controller.ts` yourself in your editor, then start narrating.

```
In products.controller.ts, find the getProducts method. Add two
query params, page and limit. Default page to 1 and limit to 20.
Then in products.service.ts, add offset and limit to the query —
offset is (page - 1) * limit. Then update the return type to
include a total count. Actually wait, also add a totalPages field.
Let me check how we do the query... ok use the queryBuilder. Now
update the DTO...
```

Twenty minutes of you dribbling out the implementation you're designing in real time, approving each edit, correcting yourself twice because you forgot the DTO and misremembered the query pattern. You did the engineering. The model did the typing. You could've just... written it.

**The unhobbled way.** Settings already in place:

```json
{
  "permissions": {
    "allow": [
      "Bash(npm test)",
      "Bash(npm run test:*)",
      "Bash(npm run typecheck)",
      "Edit(src/**)",
      "Write(src/**)",
      "Read(src/**)"
    ],
    "deny": [
      "Bash(git push:*)",
      "Edit(src/auth/**)",
      "Read(./.env)"
    ]
  }
}
```

acceptEdits mode on. One prompt:

```
Add pagination to the products list endpoint (GET /products).

Done when:
- Accepts page and limit query params, sensible defaults
- Response includes the items plus total count and total pages
- Match the pagination shape we already use on /orders if one exists
- Existing product tests still pass, and there's a test for
  page 2 returning a different slice
- npm run typecheck is clean

Guardrails:
- Don't touch the auth layer
- Follow the existing controller/service/DTO structure, don't
  restructure it
```

Then you go get coffee. It reads `products.controller.ts` *and* `orders.controller.ts` — because you told it to match the existing shape, so it found the pagination convention you forgot you had and stayed consistent with it. It updates the controller, service, and the DTO you would've missed on the first pass. It writes the page-2 test. It runs typecheck, catches its own type error on the response shape, fixes it. It hands you a diff.

You review the diff. That's your job now — the *review*, where your senior eye is worth something, not the dictation. Same feature. You spent your attention on the boundary and the outcome instead of on keystrokes, and it matched a convention you'd have had to remember and re-explain in the hobbled version. That's the overhang, closed.

## The checklist: scope, don't script

Before your next Claude Code prompt, run it through this:

- **Am I describing an outcome or a keystroke?** If I named a specific file or function to edit, ask whether the model could've found it. It probably could.
- **Did I write a "done when"?** A verifiable end state — tests, types, a build, an endpoint response. If done is a vibe, the result is a vibe.
- **Did I write guardrails?** The lines it can't cross. Cheap to add, saves the ugly runs.
- **Can it actually verify itself?** If done means tests pass, it needs permission to run the tests. Check `.claude/settings.json`.
- **Did I set up permissions once, or am I approving every command?** Allow the safe stuff, deny the dangerous stuff, pick the right mode. Then stop hovering.
- **Is my context wired or pasted?** Live or large context (schema, tickets, docs) → MCP. Stable and small → CLAUDE.md. A giant paste of anything that drifts is a future lie.
- **Could this be a subagent?** A cleanly scoped goal with its own done criteria is a delegation, not a conversation.
- **Where am I actually adding value?** If it's picking files, move up a level. If it's reviewing the final diff and setting boundaries — good, stay there.

The whole thing compresses to one line. **Give it a goal, give it the tools to know when it's met, give it the guardrails so it can't wander, and get out of the loop where you're just narrating.**

The model isn't the bottleneck anymore. Your habits are. Cut the rope and let the thing run.