Course · Codex · Automation
Generate the monthly report headless with codex exec
Every turn you’ve watched in this course had you standing at the end of it, reading a diff or answering a pause. This lesson removes you from one specific job - the month-end report, the chapter’s constructed chore - and the honest way to start is by naming what that removal costs, not just what it saves. The loop has four beats: read, propose, approve, apply. codex exec still runs three of them directly. What’s gone is the beat where you were the thing standing in the loop.
The tension follows immediately: everything the approve beat used to catch on the fly now has to be caught before the run ever starts, or not at all.
The headless entry point
Section titled “The headless entry point”codex exec (alias codex e) runs the agent non-interactively: it takes a prompt, runs to completion, prints the final message, and exits - the docs’ own framing is scripted or CI-style runs that should finish without human interaction. No chat, no back-and-forth. Progress streams to stderr; only the final message lands on stdout. That’s exactly the shape a month-end report needs:
codex exec "Read this month's statement from ~/budget-data/statements/,categorize each row the way app/importers/csv.py does, and total spendper category." > reports/2026-04.mdTwo deliberate choices in that one line. The statement and the report path are the chapter’s constructed artifacts, named in the chapter introduction; the categorizer and the importer it references are real. And the redirect is doing the writing - your shell creates the file, so Codex itself only has to read, which matters in a moment.
Because it’s an ordinary command-line program, it composes with anything a shell can do - redirect it, drop it in a cron entry, hand it to a CI step. It also reads stdin: with a prompt argument, the prompt is the instruction and the piped content arrives as additional context, which is how you hand a run exactly the data it should see and nothing else.
Why nothing asks you anything
Section titled “Why nothing asks you anything”Here is the first fact to get exactly right, because the tempting explanation is wrong. It is not that the run stayed safely inside its fence and so no prompt was needed. In codex exec, the approval policy defaults to never: there is no prompt to fire, ever, in a headless run. A move the policy disallows isn’t paused for a y/n that will never come - it’s rejected, and the run either routes around it or fails.
The default sandbox is just as tight: exec runs read-only out of the box. Put together, the out-of-the-box exec posture is the budget-audit pairing from the approvals chapter - never plus read-only - without you setting either dial. A job that only reads and reports - this one, with the shell doing the writing - fits it unchanged. A run that must itself edit files has to be granted that reach explicitly. Nothing is inferred from your absence; it’s simply denied.
And the walls from the extending chapter ride along. Hooks are discovered next to the session’s active config layers - the repo’s .codex/hooks.json among them - and the trust you recorded through /hooks is pinned to each hook’s hash. So an exec run in this repo still meets the money-tests gate: an edit under app/importers/ or app/services/ fires the test suite whether anyone is watching or not.
That is what putting the check on the rail instead of in the window bought. (The hooks reference doesn’t call out exec runs by name; the discovery and trust model is the mechanism, and it’s worth a one-run verification on your own setup.)
Trace the stages, ground truth first
Section titled “Trace the stages, ground truth first”Before pricing anything, get the comparison you actually want: what did this class of job cost the last time a human ran it? Your first change - closing one ticket interactively, on a repo you didn’t trust yet - took 3 turns: the ticket, the review, the commit, with about 480 estimated tokens in the window. That’s the ground truth. Now watch the same shape of job from outside a codex exec call.
The read beat still happens. To total by category, the run has to open the categorizer at app/services/categorise.py - 18 lines, about 180 estimated tokens at 10 tokens per line - and likely app/models.py and the ledger beside it. The propose beat still happens too, invisibly: there’s no chat window for a plan to show up in, but the agent still decides one before writing anything. Then apply: the report gets written. Two output routes exist - your shell redirects the streamed final message (as above), or --output-last-message <file> has the codex exec process itself write its final message to a file you name (short form -o). The flag is an output artifact of the run, not a sandboxed write by the agent’s tools, so it works under the read-only default.
Read, propose, apply. Three beats, present and accounted for, at a cost you can put a number on: a few hundred estimated tokens, paid fresh every month, because a headless run starts exactly as cold as an interactive one - it knows what it reads, plus what’s written down where Codex reads by default, and nothing else.
Where the fourth beat went, and what “1 turn” is hiding
Section titled “Where the fourth beat went, and what “1 turn” is hiding”Count turns the way the loop taught you: how many round trips did this cost? From the calling script’s side, the honest answer is one - one command, one result, done. Compare that against the 3 turns the interactive fix took, and it looks like automation just cut the cost by two-thirds. It didn’t. It changed what the number is measuring. “Turns” was built to count how many times you had to come back - and if nobody’s coming back, the count collapses to one by definition, not by improvement.
A one-turn run and a three-turn run can contain the exact same number of places something quietly went wrong; the instrument just stopped being able to tell you which.
Exit behavior: fails shut, not open
Section titled “Exit behavior: fails shut, not open”The never-ask default is what makes codex exec safe to script around at all, and it deserves its name: fails shut. The command runs to completion and exits with a status code - zero when it finished, non-zero when it couldn’t - and a disallowed action has nowhere to pause to. It just fails.
codex exec "Generate April's budget report." --output-last-message reports/2026-04.md \ && echo "report written" \ || echo "report failed - check the log"(An illustrative command shape; -o is its short form. The flag writes the final message itself, so the run stays read-only.)
A dead job that exited non-zero is the safe outcome - better than one that quietly waved itself through, and far better than one that hangs forever on a prompt nobody’s there to answer. Fail shut is the whole reason the approve beat can be removed without the loop becoming reckless by default.
The stage you can’t take back
Section titled “The stage you can’t take back”Here’s the honesty this lesson owes you, and it belongs right here, not at the bottom: apply is the point of no return, and the exit code doesn’t cover it. In your first change, the review beat caught the neighboring float parse - a human read the diff before anything became permanent. That gap doesn’t exist in a headless run. The sandbox decides whether apply is allowed - a permissions question, settled in advance.
Nothing after it decides whether apply was right, until the exit code says so - and the exit code speaks only for what you actually wired it to check. A wrong-but-permitted write, a mis-totaled category sitting correct-looking in reports/2026-04.md for a month, unread: that’s not a crash. It’s a number nobody caught, waiting for the day someone budgets against it.
The report now generates itself, and you know exactly which stage in that run nothing is watching. But everything here still assumes you fired the command. The transaction check needs something else to press the button - a push - and that changes what has to be granted, and when. That’s the next lesson.