Technical
Timothy Yang

Follow the data, not the calls, when you debug with an agent

Agents debug by reading files until they have a theory, and the theory is usually wrong. Here's the alternative: one structured probe at the boundary, a failing and a passing payload, and the actual answer in thirty seconds.


You paste a stack trace into Claude Code and ask why the order total came out as $0.00. Forty tool calls later it has read eleven files, built a confident theory about your discount engine, and added a ?? 0 that makes the symptom vanish without going anywhere near the bug.

That's following the calls. It's the default behaviour for almost every agent, and for most of us when we open an unfamiliar repo: start at the entry point, jump to definition, read the function that calls the function that calls the function, build a mental model of the control flow, then guess. It feels rigorous. It's mostly expensive theatre.

The alternative is to follow the data. Pick one concrete record that's wrong, and trace what it actually looks like at each boundary it crosses. Not "who calls this" but "what is in this variable, right now, in the failing case, and how does that differ from the passing case."

Why the call graph lies to you

Reading code tells you what could happen. Data tells you what did. Those are different questions and only one of them is the bug.

The call graph is also enormous. Every branch you read is a possible world, and most of them never execute for your failing input. You burn attention — and if it's an agent, tokens — mapping territory nobody walks through. Mike Acton put the general version of this bluntly in his CppCon 2014 keynote: "The transformation of data is the only purpose of any program" (isocpp.org). He was talking about performance, but the debugging corollary holds. The functions are scaffolding around a data transformation. If the output is wrong, the wrongness is in the data, and it entered at a specific point you can find.

The bug: totals silently going to zero

Here's a real-shaped example. Ecommerce app, TypeScript, Postgres. Some orders — maybe one in thirty — show a total of $0.00. No errors, no exceptions, nothing in Sentry. The pricing function looks fine:

// lib/pricing.ts
export function orderTotal(cart: Cart, discount: Discount | null, taxRate: number) {
  const subtotal = cart.items.reduce((sum, i) => sum + i.unitPrice * i.qty, 0);
  const discounted = subtotal - (discount?.amount ?? 0);
  return Math.round(discounted * (1 + taxRate) * 100) / 100;
}

Ask an agent "why is orderTotal sometimes zero" and watch it follow the calls. It opens orderTotal, then the checkout route that calls it, then the cart loader, then the discount service, then the promo webhook handler, then the types file, then the tax config. Somewhere around file eight it notices discount?.amount has type number in the interface but the service returns whatever the provider gave it, and produces this:

const discounted = subtotal - Number(discount?.amount ?? 0);

Plausible. Defensible in review. Completely useless, because the value was already coercing cleanly. The actual bug: the discounts table stores amount as text, and the promo provider that went live in March sends cents as a string — "1000" — while the legacy provider sends dollars as a number — 10. JavaScript's - operator happily coerces "1000" to 1000, so a $40 cart becomes 40 - 1000 = -960, which a clamp three layers downstream turns into zero. No throw, no NaN, no type error at runtime. Number() changes nothing.

You cannot find that by reading. There's no line of code anywhere in the repo that says "sometimes this is cents."

What following the data looks like instead

Two commands. First, ask the database what it actually holds:

psql "$DATABASE_URL" -c "\d+ discounts"
psql "$DATABASE_URL" -c \
  "select source, amount, length(amount) from discounts order by created_at desc limit 20"

The \d+ output shows amount | text | not null and the sample rows show "10.00" next to "1000". Thirty seconds. Done. The eleven-file read never got here because nothing in the TypeScript admits the column is text — the ORM types say number and everybody, human and model alike, believed them.

Second, when the schema isn't enough, probe the boundary. One line, structured, temporary:

// lib/pricing.ts — delete before merge
console.log(JSON.stringify({
  probe: "pricing.input",
  orderId,
  subtotal,
  discountAmount: discount?.amount,
  discountType: typeof discount?.amount,
  discountSource: discount?.source,
  taxRate,
}));

Run the failing order. Run a passing order. Now you have two JSON objects that differ in exactly one interesting way, and you have them as facts rather than as an inference from reading code.

Hand the agent evidence, not a reading list

This is where agents get genuinely good, because consuming a small pile of concrete runtime state is something they do extremely well. There's research pointing the same direction — ChatDBG wires an LLM directly into a debugger so it can query stack frames and variables rather than only read source, and the authors report that this dialogue meaningfully helps with identifying root causes. You can approximate that without any special tooling. Just give it the state.

Two probe lines from lib/pricing.ts. Same code path, different outcome.

FAILING: {"probe":"pricing.input","orderId":"o_8812","subtotal":40,
"discountAmount":"1000","discountType":"string","discountSource":"promokit","taxRate":0.1}

PASSING: {"probe":"pricing.input","orderId":"o_8790","subtotal":40,
"discountAmount":10,"discountType":"number","discountSource":"legacy","taxRate":0.1}

Schema: discounts.amount is `text not null`.

Find every place between the promokit webhook handler and orderTotal where
that value is read, written, or coerced. List them with file:line.
Do not propose a fix yet.

That last line matters more than it looks. Left alone, agents jump to a patch, and the patch lands at the symptom because that's where they were looking. Forcing an inventory pass first keeps them in evidence-gathering mode, and the inventory is what surfaces the other three call sites that also read amount and will also be wrong once you fix this one.

The fix, once you know the real shape, isn't a coercion. It's a boundary that refuses ambiguous data — normalise to integer cents at the webhook, migrate the column, and make the type honest. Alexis King's "Parse, don't validate" is the canonical write-up of that idea and it's short; go read it rather than my paraphrase.

Make the data greppable before you need it

Scattered console.log calls are following the data badly. You get a wall of unlabelled output and no way to compare runs. One structured line per interesting boundary, with all the fields on it, is the version that scales — the observability crowd calls these wide events, and it's the same instinct behind attaching structured attributes to logs rather than stuffing everything into a message string (Uptrace's OTel logs guide covers the data model if you want the standards-compliant version).

Structured lines mean you can aggregate instead of squint:

grep '"probe":"pricing.input"' logs.ndjson \
  | jq -c '{src: .discountSource, t: .discountType}' \
  | sort | uniq -c | sort -rn
   2841 {"src":"legacy","t":"number"}
     97 {"src":"promokit","t":"string"}

Ninety-seven affected orders, and the blast radius is now a number you can put in a Slack message rather than a vibe. This is the bit that following the calls structurally cannot give you: reading code never tells you how often.

If you're dealing with anything remotely personal, dump shapes rather than values. A tiny helper covers most cases:

const shapeOf = (v: unknown): unknown =>
  Array.isArray(v)
    ? [shapeOf(v[0])]
    : v && typeof v === "object"
      ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, shapeOf(x)]))
      : typeof v;

shapeOf(payload) gives you {"amount":"string","currency":"string","meta":{"tier":"number"}} — enough to find a type divergence, safe to paste into a chat window, safe to leave running in staging.

Freeze the evidence into a test

The probe found the bug. The probe is also, already, a regression test — you just have to stop deleting it.

mkdir -p test/fixtures
psql "$DATABASE_URL" -t -A -c \
  "select row_to_json(d) from discounts d where source='promokit' limit 1" \
  > test/fixtures/discount-promokit-cents.json
import fixture from "./fixtures/discount-promokit-cents.json";

test("promokit discounts are normalised to integer cents", () => {
  const d = parseDiscount(fixture);
  expect(d.amountCents).toBe(1000);
  expect(orderTotal(cart40, d, 0.1)).toBe(33);
});

Real payload, captured from the system that actually produced it, checked into the repo. When the provider changes their serialisation next quarter, this fails instead of quietly zeroing another ninety-seven orders. And it gives future-you (or a future agent) a worked example of the true shape sitting right next to the code, which is worth more than a paragraph of comments.

While you're there: generate the schema into the repo so nobody has to guess again.

pg_dump --schema-only --no-owner "$DATABASE_URL" > docs/schema.sql

Reference docs/schema.sql from your CLAUDE.md or rules file. The number of agent-generated bugs that trace back to a hallucinated column type is not small, and this costs one line in a Makefile.

Where following the data falls over

It needs reproduction. If the bug only happens in prod, only under load, only for one customer in Auckland, you can't drop a probe and hit refresh — you're back to reading code and reasoning about possible worlds until you can get the data out. That's the real tradeoff and I'm not going to pretend otherwise. It's also the argument for having structured logs before the incident rather than after.

It's weaker on genuine control-flow bugs. Wrong branch taken, feature flag evaluated in the wrong order, a race between two writers, an effect firing twice in React strict mode. The data at each boundary can look completely valid and the bug is in the sequencing. Probes still help — timestamp them and log the branch taken as a field — but here reading the code earns its keep.

And it's slower to start. Following the calls gives you the illusion of progress in ten seconds. Instrumenting a boundary, reproducing the failure, and capturing a passing case might take fifteen minutes before you learn anything. It's a real cost. It's just smaller than the cost of three rounds of plausible wrong fixes.

"Isn't this just printf debugging?"

Yes. Completely. The reason it's worth 2,000 words in 2025 is that the economics changed.

Printf debugging was always effective and always felt beneath us, so people skipped it. Now there's a second party in the loop who is superb at reading a JSON blob and terrible at deciding to generate one. Your agent can't log into staging, can't click through your checkout with a test card, can't see your database unless you show it. Left to itself it will do the thing it can do — read files — and it will do a lot of it.

The other objection is context. "My repo is indexed, the model has a huge window, let it read." Reading is not free even when it's cheap. Claude Code's own answer to this is subagents, which run with their own context so exploratory file-reading doesn't flood the main conversation — the docs are explicit that the parent gets a summary rather than the raw exploration. That's a good mitigation for a problem you can often avoid entirely. Forty thousand tokens of source produces a summary. Two probe lines and a \d+ produce an answer.

Do this one thing this week

Find the last bug an agent "fixed" that you can't actually explain. You know the one — the diff was small, the tests went green, and you merged it with a slightly uneasy feeling.

Check out the commit before the fix. Add one structured probe line at the boundary where the wrong value first appears. Reproduce the failing case and one passing case. Diff the two JSON objects.

If they differ in a way no line of code in your repo acknowledges — a string where a number was promised, cents where dollars were assumed, a null that the types swear is impossible — then the original fix was a bandage, and you now know what's underneath it. Save the failing payload as a fixture and write the test.

If you try it and turn up something ugly, come post the two JSON blobs in the Discord — discord.gg/3scUHe7B. Half the fun of a good shape bug is watching other people guess wrong before the reveal.

Timothy Yang

Timothy Yang

Founder & CEO, DrillCall

Four businesses built and exited, including a micro-task marketplace with 170,000+ users. Now building DrillCall and running Vibe Coding Club from Sydney.

Build with us

Vibe Coding Club is where people who ship with AI tools compare notes. Bring what you're building.

JOIN THE DISCORD →