Book a call
LESSON14mVERIFIED 2026-08-02 · CLAUDE CODE 2.1.193 · CODEX CLI 0.146.0

Give it the shape, not the steps

A numbered procedure is a plan you made before anything read the repo. We gave one step list to two harnesses: one obeyed it into a bug, one binned it.

The prompt you write after you've been burned

The following is what we came up with after a bad experience earlier when asking about rate limiting and getting an unmergeable output we thus decided to write the prompt ourselves specifying file, function, and call-site and focusing on the functional requirements so it's basically like a specification. But… It turns out we had written it before having seen the repository; it was based on our recollection from weeks ago and a hypothesis where this new feature should go. Which makes it — just like plans the model comes up with — a coherent description of an implementation that might not work given the current state of the code.

We have already covered in the agent loop lesson that it's useful to review the plan the model suggests, but the difference is that when the model hands us a plan we review it, while nobody reviews the one we write ourselves. And then halfway through this step four we realised we're writing code in English and slowly.

Anthropic themselves demonstrate it on the Claude Code page. They take a four-word prompt — "fix the failing tests" — and show what the model might do with it:

  1. Run the test suite to see what's failing

  2. Read the error output

  3. Search for the relevant source files

  4. Read those files to understand the code

  5. Edit the files to fix the issue

  6. Run the tests again to verify

Six steps, none of them typed by a human. As they put it, "Claude decides what each step requires based on what it learned from the previous step" — so at the end of the day a process is being outlined either way, and the only question is when it is being outlined: before reading the code, or after.

The experiment

To make this more concrete we've set up a little repository with an orders API (around 120 lines of Python), with four tests. Under the hood, the server runs each function defined in app/middleware.py::MIDDLEWARE in a loop until one of them returns a response (in which case the request is finished) or until they all are exhausted (in which case what's left goes to one of five handlers defined in app/handlers.py).

There's already a require_auth middleware there and it already excludes /health from authentication, and the four tests are green.

Now we've come up with two ways of expressing the same task — rate limiting. Here they are, exactly as we sent them. The step version:

TEXT
Add rate limiting to this API. Here is how: 1) open app/handlers.py, 2) add a function
check_rate_limit(user) that returns False when that user has made more than 100 requests in the
last 60 seconds, 3) call it at the top of every handler and return {"status": 429, "body":
{"error": "rate limited"}} when it returns False. Standard library only.

And the shape version:

TEXT
Add rate limiting to this API. It has to hold this shape: a user over 100 requests in 60 seconds
gets back status 429, and the handler never runs for that request. /health stays open to everyone.
app/handlers.py is not edited - turning rate limiting on or off must never be a change to a
handler. Standard library only, and the existing tests still pass.

So. As you can see, 58 words versus 65 words, so it's not that the shape version is generally less verbose; it's about giving instructions on how to do sth versus what needs to hold afterwards.

To compare we've run each prompt once on each tool — four runs, every one of them on its own fresh copy of the repo — using the latest versions of both (Claude Code 2.1.193 and Codex CLI 0.146.0), setting default model parameters for both.

Codex did what the steps said

The step version, with Codex:

  • Added 36 lines to app/handlers.py (including check_rate_limit with the specified name)

  • Added a two-liner in the beginning of all five handlers

  • Didn't modify middleware.py

  • Didn't add any tests

And everything it did was correct. But. We have said "the user", and it turns out that under the /health endpoint the user is None, so every unauthenticated user is sharing their rate limit counter. Which means the 101st request to /health within a minute returns 429.

TEXT
$ python3 -c "from app.server import Request, handle; \
    print([handle(Request('/health'))['status'] for _ in range(101)][-1])"
429

And if there's an LB in place polling /health the service is being taken off rotation.

But… The four original tests are still green.

TEXT
$ python3 -m unittest discover -s tests -t .
....
----------------------------------------------------------------------
Ran 4 tests in 0.000s

OK

Which means that by using this prompt we could have ended up with a production bug and not known it, because we followed instructions, all tests passed, and the service was broken.

Claude Code ignored the steps

The step version, with Claude Code:

  • Didn't modify app/handlers.py

  • Added check_rate_limit to middleware.py (with the specified name)

  • Created rate_limit middleware in-between require_auth and add_request_id

  • Excluded /health from the feature despite not mentioning it

  • Added four tests; eight in total, all green

Which is a nicer architecture than we've asked for, but not what we've asked for. We mean… The last paragraph of Claude's final message says they could move check_rate_limit to app/handlers.py (calling it on top of every handler) which is just a small thing either way. But… It was the last paragraph of a long message and they were talking about other things there, so it's easy to miss for a developer who just wants to make sure the run finished.

Both harnesses agreed on the shape prompt

The shape version, with both:

  • Claude Code: Created app/ratelimit.py containing RateLimiter class; created rate_limit middleware after require_auth in middleware.py; documented the enabling/disabling switch in the README; 11 tests in total

  • Codex: Added RateLimiter-like functionality directly to middleware.py; 7 tests in total

So they both converged on a similar solution, differing in their preference for where to put the limiter and what to write as the error body (too many requests vs rate limit exceeded), but leaving app/handlers.py intact.

The two tests Claude Code added during the shape prompt run which it didn't during the step one:

  • Checks that a request is not being handled if the user is being rate limited by asserting the store hasn't been modified

  • Makes sure the rate_limit middleware is ordered after require_auth by checking MIDDLEWARE from app/middleware.py

The thing is — we've just moved the constraint from the prompt to something the tests can fail on. Which is a nice thing, but wasn't part of what we've asked for.

One number so this doesn't read as an advert: on Claude Code the shape prompt cost more — 21 turns and $0.69 against 14 turns and $0.44 — because it did more work. And four runs is a demonstration, not a benchmark; re-run it and the details will move.

The shape

A shape isn't about writing a less detailed version of a prompt. It's about constraining the problem differently. And if you were to visualise it, it'd be four cells:

  • Observable — the outcome; the thing that can pass or fail; in this case: "a user over 100 requests in 60 seconds gets back status 429, and the handler never runs for that request"

  • Boundary — the limits of the landscape you explore; what parts of the repo are available; in this case: "app/handlers.py is not edited"

  • Invariant — the properties you want the code to retain in the future; the seam; in this case: "turning rate limiting on or off must never be a change to a handler"

  • Exemption — the exception that proves the rule, just to be on the safe side; in this case: "/health stays open to everyone"

It's obvious that we all get to the observable cell at some point. It's usually cheap to include the boundary and the exemption, you just need to have been burnt once. But the invariant is what we find interesting here, because we haven't talked about it in terms of this particular diff; it's not about the current state of the repo but a property that the code must retain.

Which is actually what Claude Code was communicating during their run:

  • There's a line in the README saying you turn it off by dropping rate_limit from MIDDLEWARE — "no handler changes either way"

  • And there's a docstring for one of the tests saying: "Turning the limit on or off is an edit to MIDDLEWARE, never to a handler."

It's not that easy to isolate the invariant with just one run per cell, but what's important is that both runs were compliant with it in terms of where they place their seam.

The 429 rule characterises the feature, and this invariant describes the future of the codebase; it's going to be there long after we all forget about this task.

None of these four cells is about mechanism. There's no deque or middleware.py there.

But if you feel like it would make sense to standardise on a library or define a convention for your organisation, you can always make it an invariant — the way you'd write "it goes through the existing middleware chain" rather than "add it to middleware.py".

If you think about it, you could ask yourself: "What if it were to return smth I haven't thought of? Would I like it?" If the only honest answer is "No, that'd be a bug", then steps are what you need. But if you want steps, make sure you're aware of it.

Converting steps into a shape

Which means we should keep this list, and ask about every step — what is it for?

  • Opening app/handlers.py — "So we can apply the check to everything" — which is a shape — "No request goes to a handler without being checked"

  • Adding check_rate_limit(user) — "So we can implement 100 requests per user in 60 seconds" — which is a shape — "If they exceed it, they get 429"

  • Calling it atop every handler — no purpose, no shape

The thing is, the third line wasn't about anything; we didn't know where to put the feature and just guessed. But that's the thing that got us into a production bug. And this is what's important: such lines are transparent during writing and emerge only when you ask yourself — what was it for? What was I protecting with it?

What you need to retain is what withstands the question; being precise about the target was never the problem, being precise about the route is.

"Be specific" vs shape

This might be counterintuitive given how often we see advice like this on the internet. But the thing is — it's worth reading what they mean by "specific", not what you think it means. Like:

  • OpenAI's Prompting page (which is part of Codex docs, but is about ChatGPT in general), opens its advice with:

    Start with the result, not a detailed list of steps.

    But…

    Describe a process when the process itself matters. Otherwise, leave ChatGPT room to search, compare information, and adjust its approach.

    And a few paragraphs on:

    You don't need to control every step ChatGPT takes.

And…

  • OpenAI's Best Practices guide suggests a default prompt for Codex with four slots, none of them being "steps":

    • Goal

    • Context

    • Constraints

    • Done when: What should be true before the task is complete

Which isn't actually that different from what we've outlined above:

  • Observable — "a user over 100 requests in 60 seconds gets back status 429, and the handler never runs for that request"

  • Boundary — "app/handlers.py is not edited"

  • Invariant — "turning rate limiting on or off must never be a change to a handler"

  • Exemption — "/health stays open to everyone"

The only thing we could say here is that OpenAI doesn't name the invariant separately, but they do talk about constraints.

On the other hand…

  • Anthropic themselves, in the Claude Code guidance page, advise to be specific and provide a few before-and-after examples. Like this one, where "implement a function that validates email addresses" becomes:

    write a validateEmail function. example test cases: [email protected] is true, invalid is false, [email protected] is false. run the tests after implementing

Which is about inputs and outputs and running the tests, but not about how to implement it.

And these are a few of the other examples they give that do involve steps:

  • write a failing test that reproduces the issue, then fix it

  • take a screenshot of the result and compare it to the original. list differences and fix them

Which are all about testing the feature, not implementing it.

And…

  • … if they mention a file in this section, it's either as a model to follow ("HotDogWidget.php is a good example") or as a starting point for investigation ("check the auth flow in src/auth/, especially token refresh"), never as a place to make edits

  • … even "the over-specified CLAUDE.md" is on their list of failure patterns, and the fix they give for it is to "ruthlessly prune"

Which is basically what Anthropic call altitude in their Context Engineering page — the Goldilocks zone between two failure modes:

  • At one end, "engineers hardcoding complex, brittle logic in their prompts to elicit exact agentic behavior"

  • At the other, "vague, high-level guidance that fails to give the LLM concrete signals"

Which — even though these are about system prompts rather than task prompts — are the two ends we're steering between. Our own step prompt sat at the brittle end: it never said what had to be true afterwards, it named a file, a function and a call site.

And…

  • … what they recommend is a middle ground:

    specific enough to guide behavior effectively, yet flexible enough to provide the model with strong heuristics

  • … and their closing note is that the line keeps moving in one direction: "smarter models require less prescriptive engineering"

And…

  • … OpenAI's GPT-5.1 Prompting guide is very direct about it. The planning prompt it recommends tells the model to:

    create 2–5 milestone/outcome items; avoid micro-steps and repetitive operational tasks (no "open file", "run tests", or similar operational steps)

    Which is exactly what we do at the beginning of this step prompt; we tell the model to open app/handlers.py, which is the example they picked

  • … the guide before it, for GPT-5, also describes the cost of being too specific. Because the model follows instructions "with surgical precision":

    poorly-constructed prompts containing contradictory or vague instructions can be more damaging to GPT-5 than to other models, as it expends reasoning tokens searching for a way to reconcile the contradictions rather than picking one instruction at random

  • … which makes perfect sense, as a lengthy procedure creates plenty of room for contradictions to appear; and the contradictory prompt OpenAI provide further down this guide is entirely about ordering — "before any scheduling step" against "as the first action"

When steps are good

Using imperative form isn't a mistake. The exception is the one OpenAI state in the same Prompting guide:

Describe a process when the process itself matters.

Which basically means that we can use steps if:

  • … The order matters and you're the only person aware of it — like migration before deploy, or lockfile before install

  • … Deviation is the defect — like renaming 40 files; you need to be careful here not to introduce a bug by being creative

  • … The process is already battle-tested and needs to be performed exactly the same way every time — at which point it's no longer a prompt thing but rather a skill that we cover in the skills chapter

And…

  • … Google's Antigravity docs actually have a boundary here:

    • Rules give the model "persistent, reusable context at the prompt level"

    • Workflows "provide a structured sequence of steps or prompts at the trajectory level"

  • … and the workflow examples they give are about deploying a service and handling PR comments

  • … But what actually works for the fans of writing steps is not that they're useful for expressing sequence but that writing them requires you to think before submitting, which is a similarly useful outcome we can achieve with a few constraints without writing so much; it's the decision we need to have, not the route we want to take

  • … It's actually the repetition that signals the wrong side of the line. If you're pasting the same procedure into your third prompt, it means you shouldn't be putting it in prompts

The practice

When you're about to submit a prompt, read it and think which sentences would still be true if the repo were arranged differently than you remember; these are the shape. Everything else is a guess made on the agent's behalf — and as we've just seen, whether it accepts that guess or drops it silently is outside your control.

j / k to move between lessons