I asked Codex to “build a worker.”
It built a CLI.
Not a bad CLI, either. Arg parsing, a --help, subcommands, the works. It was a perfectly reasonable interpretation of a vague instruction. It just wasn’t what I wanted, and worse, it was confidently wrong in a way that took me a few minutes to even articulate. I didn’t want a tool you invoke. I wanted a loop that runs. Those are different programs, and the word “worker” doesn’t disambiguate them.
That’s the thing I keep relearning about prompting models to write code: a prompt describes a wish; it doesn’t specify a program. When the description is ambiguous, and “build a worker” is almost pure ambiguity, the model fills the gaps with its priors. And the most generic prior for “a program a developer asked me to build” is a CLI. So that’s what I got.
The pivot
Instead of arguing with it in prose, I opened worker.ts and just… started writing the program. Not in TypeScript. In English. As comments. Pseudocode.
Something like:
// loop until either:
// - we pass the end time (start + maxRuntimeMinutes), or
// - we hit maxIterations (if set)
//
// each iteration:
// - check policy for the current time -> tells us what work item to make
// - for every 10 iterations, X% should be custom react components,
// the rest standard content
// - spin up a SEPARATE codex session per iteration (clean context each time)
// - the iteration result should be JSON: { iteration, kind, status, topic, artifactPath, blockers }
// - print it to stdout as one line of JSON
//
// on error: don't crash the loop, print { status: 'worker_error', error } to stderr and continue
// sleep between iterations
// exit 0 at the end
Fifty, maybe a hundred lines of that. No syntax I couldn’t have written myself; that was never the bottleneck. What I was actually doing was making decisions: it’s a for(;;) loop, not a CLI. State lives in env-driven settings. Errors are caught per-iteration so one bad run doesn’t kill the worker. Each iteration is an isolated Codex session, not a shared one. The output is newline-delimited JSON so I can pipe it anywhere.
Then I let the model fill in the syntax.
The result
The code that came back was dramatically better. Here’s roughly what worker.ts collapsed to:
const settings = getWorkerSettings()
const endTime = addMinutesToDate(new Date(), settings.maxRuntimeMinutes)
let iterationsStarted = 0
for (;;) {
if (Date.now() >= endTime.getTime()) break
if (settings.maxIterations !== null && iterationsStarted >= settings.maxIterations) break
iterationsStarted += 1
try {
const policy = await checkPolicy(new Date())
const iteration = policy.workItem.kind === 'custom_component'
? await createCustomComponent(policy)
: await createStandardComponent(policy)
process.stdout.write(JSON.stringify({ /* iteration, kind, status, topic, ... */ }) + '\n')
} catch (error) {
process.stderr.write(JSON.stringify({ status: 'worker_error', error: errorMessage(error) }) + '\n')
}
await sleep(settings.sleepBetweenIterationsMs)
}
process.exit(0)
Sixty-three lines. That’s the entire worker. Every line is a decision I made, transcribed into syntax I didn’t have to type.
And the pseudocode line I cared most about, “for every 10 iterations, X% should be custom react components,” became a real, named function in the engine behind it:
const shouldCreateCustomWorkItem = (iteration, settings) => {
if (settings.customPercent <= 0) return false
if (settings.customPercent >= 100) return true
const slot = ((iteration - 1) % 10) + 1
const customSlotsPerTen = Math.round(settings.customPercent / 10)
const customSlots = new Set(
Array.from({ length: customSlotsPerTen }, (_, i) =>
Math.ceil(((i + 1) * 10) / customSlotsPerTen)),
)
return customSlots.has(slot)
}
I never specified the % 10 arithmetic. I specified the intent, “X% per window of 10,” and let the model derive the modular arithmetic that spreads the custom slots evenly across each window. That’s exactly the right division of labour. The hard part for me was the policy (“custom components should be rationed, not random, and rationed per-window so the output stays balanced over time”). The hard part for the model was nothing. It’s the kind of fiddly index math LLMs are genuinely good at and humans fat-finger.
Why this is the right seam
The whole 5,000-line content-generation engine sits behind four function names in that loop: getWorkerSettings, checkPolicy, createCustomComponent, createStandardComponent. I didn’t write those by prompting “build a content engine” either. Each one got the same treatment: pseudocode the shape, let the model fill it in.
What I’ve landed on is a halfway house between two failure modes:
- Pure prompting (“build a worker”) hands the model your architecture decisions. It will make them, and it’ll make the generic ones. You’re outsourcing the expensive thinking and keeping none of it.
- Hand-rolling the core means typing out control flow and boilerplate you’ve written a thousand times, which is slow and error-prone in exactly the boring ways.
Pseudocode-first keeps the part that’s actually yours, the thinking of the program: its shape, its control flow, its data contracts, its failure behaviour. Then it delegates the part that isn’t: the exact syntax. You write the program in English; the model writes it in TypeScript.
And here’s the bit that surprised me: it’s often faster than hand-rolling the core, not just faster than re-prompting. Pseudocode is denser than code. I can express “separate codex session per iteration, JSON out, errors don’t kill the loop” in three lines of English that would be thirty lines of TypeScript with imports and types and try/catch scaffolding. I get to make all the decisions at high bandwidth, then pay zero marginal cost to render them into syntax.
The principle
A prompt is a description of the outcome. Pseudocode is a description of the program. Models are wildly better at filling in syntax than at inferring architecture, so the leverage move is to do the architecture yourself, at the altitude of plain-English pseudocode where it’s cheap, and push everything below that line to the model.
Concretely, the next time you’re tempted to write “build me an X”:
- Open the actual file.
- Write the program as comments. Loops, branches, data shapes, error behaviour, the JSON you expect out. Fifty to a hundred lines.
- Then tell the model: fill this in.
You’re not skipping the thinking. You’re doing exactly the thinking that matters, and nothing else. The model isn’t designing your program anymore. It’s typing it.
That’s the whole trick. Do the thinking of the program. Let the LLM fill the gaps.

