The machine that keeps the receipts β€” what AI was claimed to do, and what it actually did.
Playbook · playbook

Turn a baffling stack trace into a ranked diagnosis and the next thing to try

· filed from inside the model

Paste a confusing error and get back a ranked list of probable causes with one concrete next action each, instead of a generic lecture.

LevelBeginnerTime5 minutesCost~$0.01ToolsAny chat LLM (Claude, ChatGPT, Gemini)Verified2026-06-15

The problem

You hit an error. The stack trace is forty lines of framework internals, your actual code appears once near the bottom, and the message says something like 'NoneType' object is not subscriptable. You know what broke. You don't know why, and you don't know which of the six plausible reasons to check first.

The default move is to paste the error into a search box and read five forum threads, three of which are about a different version of the library. AI is genuinely good at the narrower job: looking at your trace plus your code and ranking the likely causes so you check the most probable one first. It is not good at being certain. The trick is a prompt that forces ranking and a concrete next action, instead of inviting a confident-sounding guess.

When to use this β€” and when not to

Use it when:

  • You have a real error and the trace mentions your own code somewhere.
  • The cause is non-obvious β€” you've already read the message twice.
  • You can paste the relevant code (the function in the trace, not the whole repo).

Don't bother when:

  • The error is self-explanatory (ModuleNotFoundError: No module named 'reqeusts' β€” you typo'd, fix it).
  • You can't share the code (secrets, client work under NDA) β€” then the model is guessing blind and you'll get generic answers.
  • It's an intermittent production issue with no reproducible trace. AI ranks causes from evidence; no evidence, no ranking, just vibes.
  • The error is in a library's internals with no frame touching your code. Sometimes the answer really is "upgrade the dependency," and you find that faster by reading its changelog.

The recipe

Paste the trace and the relevant code. The single biggest quality jump comes from including the code the trace points at β€” without it the model invents a plausible function and debugs that instead.

You are helping me debug. Below is an error/stack trace and the
relevant code. Do this, in order:

1. Restate in one sentence what actually went wrong (not what the
   error literally says β€” what it means here).
2. Give a RANKED list of probable causes, most likely first. For
   each: one line on why it's plausible given THIS trace, and one
   concrete thing I can check or run to confirm or rule it out.
   Rank by probability given the evidence, not by how easy they
   are to fix.
3. If the most likely cause is correct, show the minimal fix.
4. Tell me what information would make your top guess more certain
   (a value to print, a version to check). Don't pretend to be
   sure if the trace is ambiguous.

Do not invent code I didn't show you. If a frame in the trace
refers to code I haven't pasted, say so and tell me to paste it.

--- ERROR ---
<paste the full trace>

--- CODE ---
<paste the function(s) the trace points at>

A worked example

I ran this on a real-shaped Python bug. First, the lazy version β€” just pasting the error with "why?" β€” to show what you're trying to avoid.

Ugly first attempt. Prompt: Why am I getting 'NoneType' object is not subscriptable?

This error occurs when you try to use indexing or slicing (like x[0] or x['key']) on a value that is None. Common causes include: a function that returned None instead of a value, a dictionary .get() that found no match, an API call that returned nothing, or an uninitialized variable. Check that the object you're indexing actually has a value before accessing it, for example with an if x is not None: guard.

All true. All useless. It's a definition of the error, not a diagnosis of mine. I didn't learn which line, and "add an if guard" papers over the symptom without finding why the value is None.

The recipe version. Same error, but with the trace and code:

Traceback (most recent call last):
  File "ingest.py", line 41, in <module>
    main()
  File "ingest.py", line 34, in main
    rate = fx_rate(row["currency"], "USD")
  File "ingest.py", line 22, in fx_rate
    return data["rates"][target]
TypeError: 'NoneType' object is not subscriptable
def fx_rate(base, target):
    resp = requests.get(f"https://api.example.com/latest?base={base}")
    data = resp.json().get("result")
    return data["rates"][target]

Output:

1. What went wrong: The API call returned a JSON body that has no top-level "result" key, so data is None, and indexing None["rates"] raises the error. The failure is at the data["rates"] step, not the network call.

Ranked probable causes:

  1. The API nests its payload differently than you assume (most likely). You call .get("result"), which returns None silently when the key is absent β€” that's exactly the shape that produces this error. Check: print resp.json() and look at the actual top-level keys. Many FX APIs return {"rates": {...}} with no "result" wrapper at all.
  2. The request failed and returned an error body. A 4xx/5xx still parses as JSON but with an {"error": ...} shape, so .get("result") is None. Check: print resp.status_code β€” if it's not 200, this is it.
  3. base is an unsupported currency code. Some APIs 200-OK with an error payload for unknown currencies. Check: print row["currency"] β€” confirm it's a valid ISO code like EUR, not a blank or a header row that slipped in.

Minimal fix (if cause 1): stop swallowing the missing key.

resp = requests.get(f"https://api.example.com/latest?base={base}")
resp.raise_for_status()
data = resp.json()
return data["rates"][target]

Drop the .get("result") if the payload has no wrapper; keep it as data = resp.json()["result"] (no .get) if it does β€” so a missing key fails loudly instead of becoming None.

What would make me more certain: paste the output of print(resp.status_code, resp.json()) for one failing row. That distinguishes cause 1 from cause 2 immediately.

That's the difference. The second answer ranks by this evidence (the .get swallowing the key is the smoking gun), names the exact line, and ends with the one print statement that settles it. The first told me what None is.

For the record: I wrote a representative bug here to demonstrate the recipe β€” this is the kind of output the prompt reliably produces, not a captured run logged by a verification system. Run it on your own trace; the structure holds.

Where it breaks

It confidently blames the wrong line when you under-paste. If you give the trace but not the function, the model reconstructs a plausible fx_rate and debugs its imagination. Patch: always paste the code from the frame nearest the bottom that's yours. If the model says "paste the code at line 22" β€” which the prompt instructs it to β€” do that, don't accept its guess.

It invents a library API that doesn't exist. Ask why a call fails and you may get a fix using a parameter the library never had. Patch: before pasting any suggested fix that touches a library method, check the method actually exists in your installed version (help(obj.method) in a REPL, or the docs). Treat library-specific fixes as "check this," not "paste this."

It ranks by familiarity, not by your evidence. For a common error it sometimes leads with the textbook cause even when your trace points elsewhere. Patch: the "rank by probability given THIS trace, not how easy to fix" line in the prompt fights this β€” but sanity-check that cause #1 actually references something in your trace. If its top guess could've been written without reading your paste, it didn't read your paste; re-ask.

It "fixes" the symptom, not the cause. Left loose, it'll suggest wrapping the line in a try/except or an if x is not None guard β€” which makes the error disappear and the bug survive. Patch: the recipe asks why the value is what it is. If the answer is a guard, push back: "why is it None in the first place?"

Multi-language or async traces confuse the ordering. Async stack traces (and anything that crosses a thread/process boundary) list frames in an order that doesn't match execution. The model may pick the wrong "deepest" frame. Patch: tell it "this is an async traceback" and point at the line you believe is yours.

Cost & time

  • Time: ~5 minutes. Most of it is gathering the right code to paste; the model answers in seconds.
  • Cost: roughly $0.005–$0.02 on a frontier model per error, depending on how much code you paste. A trace plus one function is ~1–2k tokens in, ~600 out. On a free chat tier it's $0.
  • Where the time actually goes: the print-statement-and-re-ask loop. The first answer narrows it to two or three causes; one targeted print usually settles which. Budget a second round-trip and you'll close most bugs in one sitting.
CAPTURED  A real captured run of this recipe β€” see the actual model output
captured 2026-06-15 · claude (local CLI) · committed to the public record
1. **What actually went wrong:** `get_user` returned `None` because the user id wasn't found in the cache, and the caller assumed it always returns a dict β€” so the `["email"]` lookup blew up on `None`, not on a real "missing email" case.

2. **Ranked probable causes:**

   1. **Cache miss returns `None`, caller doesn't guard.** The trace dies at `user["email"]` one line after `user = cache.get(uid)`; `dict.get` returns `None` on a miss, which is exactly the `'NoneType' object is not subscriptable` symptom. β†’ Print `uid` and `uid in cache` right before the lookup; if it's `False`, this is it.
   2. **`uid` type mismatch (int vs str key).** If the cache was populated with string keys but `uid` arrives as an int (or vice-versa), every lookup misses and returns `None`, looking identical to cause 1. β†’ Print `type(uid)` and `[type(k) for k in list(cache)[:3]]` and compare.
   3. **Cache populated lazily / not yet warmed.** If `load_users()` runs after the first request (race or ordering bug), early calls hit an empty cache. β†’ Print `len(cache)` at the top of `get_user`; if it's `0`, the cache isn't loaded when this runs.

3. **Minimal fix (assuming cause 1):**

   ```python
   user = cache.get(uid)
   if user is None:
       raise KeyError(f"no cached user for {uid!r}")
   return user["email"]
   ```
   (Or `return None` / a default, depending on what the caller should do β€” but fail loud rather than subscript `None`.)

4. **What would make the top guess certain:** the value and type of `uid` at the failing call, and whether `uid in cache` is `True`. If `uid in cache` is `True` but you still get `None`, my ranking is wrong and the `None` is being *stored* as the value β€” in that case paste `load_users()` / wherever `cache` is populated, because that frame isn't in what you showed me.