Course · Codex · Models & effort
Set model_reasoning_effort to match how hard the task really is
First job of the afternoon: the category filter. Open app/main.py and the pattern is already there - GET /transactions is a decorator, a two-line handler, and a call to ledger.all(). The ticket asks for its sibling: an optional category parameter that delegates to ledger.by_category() when set. Nothing to invent, nothing to weigh. That is exactly why it goes through the dial first - if the code ever changes shape as the effort moves, the dial bought something the pattern hadn’t already answered.
What the effort dial controls
Section titled “What the effort dial controls”Reasoning effort is how hard the agent works a problem out before it answers - weighing approaches, ruling options out, planning the change - instead of replying straight off the prompt. Codex exposes it as one setting, model_reasoning_effort, with medium as the client-side default (a model’s catalog entry can advertise its own default):
minimal → low → medium → high → xhigh → max → ultra # default: mediumWhich levels your model actually offers is model-dependent - xhigh, max, and ultra in particular are not available everywhere (as of October 2026, the config reference lists low through ultra, and the CLI source also still accepts minimal) - so check the config reference for the list your model accepts rather than trusting a remembered one.
Set it for a stretch of work in config.toml:
model_reasoning_effort = "low"or override a single launch without touching your standing default, using -c:
codex -c 'model_reasoning_effort="low"' "add an optional category query parameter \ to GET /transactions in app/main.py, filtering with ledger.by_category"-c key=value overrides any config value for that run only. Its values parse as TOML where possible, so quote string values explicitly when needed.
The obvious answer, planted first
Section titled “The obvious answer, planted first”Fix the answer before running anything, the way you’d want a teammate to check their own work against it. Three or four added lines in app/main.py: the category parameter, and a branch - ledger.by_category(category) when it is set, ledger.all() when it isn’t. Same response shape as the unfiltered listing; an unknown category returns an empty list, exactly as a filtered-empty ledger should. Every level below should land there. The moment one doesn’t, that is the level failing, not the endpoint being hard.
Constructed comparison, labeled once for the chapter: the prompts in this chapter’s blocks are yours to send; the replies are reconstructions built to show what changes between levels, never captured runs.
> add an optional category query parameter to GET /transactions in app/main.py, filtering with ledger.by_category (model_reasoning_effort = minimal)
• Read app/main.py • Edited app/main.py (+3) - category param; by_category when set Done.Matched, straight away. No deliberation shown because none was needed - the pattern did the reasoning before the prompt did, and by_category was sitting there waiting to be called.
> (same prompt) (model_reasoning_effort = low)
• Read app/main.py • Read app/services/ledger.py • Edited app/main.py (+3) - same three lines Done.Also matched. It read the ledger as well as the route before writing - one extra read, same answer. The cheap stops aren’t sloppier here; the task simply has nothing for extra depth to find.
> (same prompt) (model_reasoning_effort = medium, the default)
• Read app/main.py, app/services/ledger.py considering: return 404 when the category matches nothing? the ledger methods return [], so [] it is • Edited app/main.py (+3) - same three lines Done.Matched, barely. That considering line answers a question the code two lines in had already answered - handlers return whatever the ledger gives them. Harmless at this size, but it is the dial starting to spend on a decision that didn’t need making.
> (same prompt) (model_reasoning_effort = high)
Thinking… Should the parameter be validated against the taxonomy in categorise.py before querying? Should unknown categories 404? Should this be a separate /transactions/by-category endpoint instead of a query parameter? Weighing REST-purity against consistency-with-the-existing-routes… • Read app/main.py, app/services/ledger.py, app/models.py • Edited app/main.py (+3) - same three lines Done. (Kept the query parameter; matches the existing route shapes.)Overkill. Same three lines, now with a paragraph of deliberation over questions the file had already closed - every route in app/main.py is a decorator, a handler, and a ledger call, and none of them validates anything. The codebase answered before the model asked.
> (same prompt) (model_reasoning_effort = xhigh)
Thinking… [revisits validate-against-taxonomy; revisits the separate endpoint; revisits whether by_category should take a default argument; revisits pagination, which nothing in the repo does] • Read app/main.py, app/services/ledger.py, app/models.py • Edited app/main.py (+3) - same three lines Done.Overkill, and slower. Every question it raised, the codebase had already closed. xhigh - where a model offers it - is for a turn that is genuinely gnarly. This isn’t one, and the three lines say so, identical to minimal’s five stops later.
Five stops, one answer: on a task with no fork, effort past low buys deliberation over questions the repo already closed - a slower, pricier route to the same code.
The fork task asks the opposite question
Section titled “The fork task asks the opposite question”The wiring job is the other shape. app/services/categorise.py holds the keyword table - spotify and netflix to entertainment, uber and lyft to transport, whole foods and trader joe to groceries, starbucks to dining, amazon to shopping, else uncategorised. The function’s semantics are fixed: lowercase, substring, first match wins. What nothing has decided is where the hook lives - parse_csv, or the import route’s append loop in app/main.py - or what a description that matches two keywords should get.
There is no file next door to copy, so ground truth changes shape. It is simpler and just as checkable: does the level surface the fork questions at all, unprompted, before it writes any code?
> wire categorise() into the CSV import path so imported transactions get their keyword-table category (model_reasoning_effort = low)
• Read app/main.py, app/services/categorise.py • Edited app/main.py (+2) - in import_csv's loop, set txn.category = categorise(txn.description) before append Done.Underpowered - and notice it isn’t wrong the way a bug is wrong. The route hook works; tests would pass. What shipped is a decision that was never surfaced as one: the hook went where the prompt’s phrasing leaned, and nothing in the turn asks whether parse_csv is the better seam, or what two matching keywords should do. You would catch it in review only if you already knew the questions.
> wire categorise() into the CSV import path so imported transactions get their keyword-table category (model_reasoning_effort = high)
Thinking… Hook: parse_csv or the route? parse_csv is where a description first becomes data; its only caller is the import route; the ledger stores whatever it's handed. Hooking the route leaves parse_csv returning half-labeled transactions. Conflicts: "Amazon Whole Foods Market" contains two keywords. The table's order is the priority - first match wins, the same semantics categorise() already has. Boundary: POST /transactions never passes the parse path, so manual entries keep their sender's category. Deliberate.
Before I write it - one import line and one category= argument in parse_csv's Transaction call, plus tests for the table hits and the uncategorised fallback. Proceed?Matched. The deliberation that was overkill on the filter is the entire value here - it found both fork questions on its own, answered them from the code’s own shape, and named the boundary the wiring leaves alone. One honest caveat: matched means it weighed the alternatives, not that the design is correct. A proposal is still a proposal. You review it the same way you’d review code, not less.
Call this earned depth: the reasoning you pay for should equal the ambiguity the task contains, and nothing past it. low on the filter wasn’t luck - the task had no ambiguity to find. low on the wiring wasn’t bad luck either - the fork was there whether or not the level went looking, and it didn’t look. Same dial, same direction of travel, opposite verdict, because the two tasks aren’t the same shape.
For one unusually hard turn inside an otherwise normal session, a -c override reaches a deeper gear without touching your default - xhigh (or max/ultra) where your model offers it (the same model-dependent list from the top of the page). Reach for it on a turn gnarlier than the wiring above; high already found this one.
Effort is not the same dial as the model
Section titled “Effort is not the same dial as the model”Keep the two knobs straight: effort is how hard a given model thinks; the model is which brain does the thinking. Every trace above held the model fixed and moved only effort - so on the wiring task, the verdicts so far are really about effort on one unnamed brain. The next lesson holds effort fixed at the level each task just earned - low for the filter, high for the wiring - and moves only the model, on the same two tasks.
You have run both jobs through the effort dial on one model. The next stop is the other knob - which model answers at all.