Series · Codex App Server in Production · Part 8 of 13

· 31 min read

Codex App Server in Production, Part 8: Stop, Steer, and the Meter

Live control over a running turn. A Stop button wired to the truth (the agent halts, the shell finishes, the files stay), a composer that redirects a build mid-swing without restarting it, and a token meter that finally answers what this turn cost.

codex · openai · fastapi · nextjs · tutorial

The turn is dead. The stream said so, in writing: "status": "interrupted", 14.0 seconds, receipt stamped. The last thing it heard was a shell loop reporting wrote section 6. Now open the workspace and count the sections that loop was writing: ten. Four of them landed after the stop. The Stop button you'll build in this part works, provably, and the first thing it teaches you is what "stop" actually means to an agent running real processes on a real machine.

That's one of three controls this part adds to Pagewright, and it's the least interesting one. By the end of this page: a Stop button that ends a turn honestly, a composer that stays live mid-build so that "actually, make the header terracotta" lands inside the running turn (no restart, no waiting, a small chip marking where it landed), and a live token meter in the header that finally answers the question every part since Part 1's meter ritual has quietly dodged: what did this turn cost? All three hang off one protocol fact the backend has been throwing away since Part 2.

The dessert, from the real run. The turn is 9 seconds old and still hammering; the second message did not queue, did not cancel anything, and did not start a turn. It went inside.

Look at the composer in that screenshot. Every chat UI you've built in this series so far, and honestly most agent UIs anywhere, treats a running turn as a locked door: input disabled or hijacked into a Stop button, the human demoted to spectator until the receipt prints. The engine never demanded that. codex app-server will accept new instruction into a running turn over a method called turn/steer, and no sibling series has an equivalent to point at, because neither the LangGraph engine you built by hand nor the Claude Agent SDK exposes anything like it. This one is a Codex distinctive, and it's the reason the input field in Pagewright never goes gray again.

One fact, three controls

Start with what the two new protocol verbs demand. From the generated schema, verified live in 0.142.4:

  • turn/interrupt takes {threadId, turnId}. Both required.
  • turn/steer takes {threadId, expectedTurnId, input}. And expectedTurnId is not a polite hint; it's a precondition. If it doesn't name the turn that is live right now, the request fails.

Both verbs name a specific turn. And here's the embarrassing part: the backend has known every turn's name all along. turn/start answers with {turn: {id, status: "inProgress", ...}}, and Part 7's run_turn awaited that response and threw it away, line 290, because nothing needed it. Live control is what needs it. So Part 8's first change is the smallest file in the series, app/turns.py, an in-memory ledger of who's on the shop floor:

backend/app/turns.py
_active: dict[str, dict] = {}
_usage: dict[str, dict] = {}
def begin(project_id: str, thread_id: str, turn_id: str) -> None:
_active[project_id] = {
"thread_id": thread_id,
"turn_id": turn_id,
"started_at": time.time(),
}

run_turn now calls turns.begin(...) on the line after turn/start answers, and calls turns.end(project_id, turn_id) in a finally, so the entry dies with the turn no matter how the turn dies. The end() call is named: it clears the entry only if it still points at the caller's turn, because a stream that lingers past its own turn (you'll meet one in the race section) must not erase the entry a newer turn wrote. And the whole thing is deliberately in-memory: a backend restart forgets the ledger, which is honest, because after a restart the turn really is gone. Durability is Part 9's job.

Checkpoint. Right now you have: a backend that can point at the live turn on any project, and nothing that uses the pointer. Three features are about to.

The Stop button, and what stopping means

The endpoint is short because the ledger did the hard part:

backend/app/main.py
if projects.get_project(project_id) is None:
raise HTTPException(status_code=404, detail="no such project")
active = turns.active(project_id)
if active is None:
raise HTTPException(status_code=409, detail="no active turn to interrupt")
try:
await client.request("turn/interrupt", {
"threadId": active["thread_id"], "turnId": active["turn_id"]})
except CodexError as exc:
# Same race as steer: the turn ended on its own first.
raise HTTPException(status_code=409, detail=str(exc))
return {"interrupted": True, "turn_id": active["turn_id"]}

Notice what the response doesn't contain: the outcome. turn/interrupt returns almost immediately, and the truth arrives where truth always arrives in this app, on the stream, as the same turn/completed every turn ends with, status "interrupted" instead of "completed". That's a gift for the frontend, because stopping needs no new event type at all. The Stop button POSTs and forgets; consumeStream maps the status it was already reading:

frontend/app/page.tsx
if (event.type === "complete") {
patchLastTurn({
status:
event.status === "completed"
? "done"
: event.status === "interrupted"
? "stopped"
: "error",
totalTokens: event.usage?.totalTokens,
durationMs: event.duration_ms,
});

The "stopped" status renders a receipt line under the turn: stopped by you, in red, next to the tokens and seconds. Hold on to those two usage lines; they're the meter section's story. First, the part of stopping nobody puts in the docs.

Stop the agent, not the world

Ask Pagewright to run a slow loop, ten iterations, two seconds of sleep each, one line appended to index.html per pass. Let it get to section six, then click Stop. Here is the stream, verbatim from the capture:

The whole lesson in one capture. The turn ended at 14.0s with the loop at section 6. The file ends with section 10.

Three facts, all verified live, all worth internalizing before you ship a Stop button anywhere:

  1. The turn ends immediately. turn/completed with status "interrupted" arrived the moment the interrupt was processed, and the agent made no further model calls. The taxi meter stopped.
  2. The in-flight item is abandoned, not closed. The command's item_start never got an item_done. Its badge in the UI is still spinning in the screenshot below, and that's the protocol telling the truth: the item's lifecycle genuinely never completed.
  3. The OS process was not killed. The shell loop kept running for roughly nine more seconds, on its own schedule, to its natural end, and every write landed. The stream stopped listening at section six; the shell kept writing through section ten.

Interrupt is cooperative at the agent level. It says "stop orchestrating", not kill -9. And once you know the workspace is truth, the right UI follows: after any stop, refresh the file list and the preview from disk, because the files, not the stream, know what actually happened. Pagewright already does a catch-all refresh on every complete, so the stopped turn's half-built (or, as here, fully-built) reality shows up on its own.

The stopped state from the real e2e run. The spinner badge is not a bug; no item_done ever arrived for it. The red receipt below it is the component telling the truth.

Shouting through the hatch

Now the good one. You know the moment: the build is forty seconds in, sections are landing, and you spot it, the wrong accent color, the missed requirement, the header that should have been terracotta. Until this part you had two options, both bad. Stop everything and restart with a corrected prompt, paying for the cold start and losing the finished sections' momentum. Or wait politely for the receipt and send a follow-up turn, watching the wrong color spread to three more sections while you wait. Steering is the third option the protocol was hiding: append the correction to the running turn and let the build absorb it without breaking stride.

The frontend's half is almost insultingly small. Send stopped yielding to Stop (both render during a run), and send() grew one routing line:

frontend/app/page.tsx
async function send(text: string) {
const prompt = text.trim();
if (!prompt || !activeId) return;
setInput("");
// The composer stays live during a run: sending mid-turn steers the
// running build instead of queueing a new one.
if (working) return steer(prompt, activeId);

The backend's half is where the design lives. The chat endpoint becomes a router with two verbs, and the ledger from section one is the switch:

backend/app/main.py
active = turns.active(project_id)
if active is not None:
try:
await client.request("turn/steer", {
"threadId": active["thread_id"],
"expectedTurnId": active["turn_id"],
"input": [{"type": "text", "text": req.message}],
})
except CodexError:
# The turn finished a beat before the message landed
turns.end(project_id, active["turn_id"])
else:
await client.queue_for(active["thread_id"]).put(
{"method": "steer/accepted",
"params": {"text": req.message, "turn_id": active["turn_id"]}})
return {"steered": True, "turn_id": active["turn_id"]}
return StreamingResponse(run_turn(project_id, req.message),
media_type="text/event-stream")

Read the two return shapes, because they're the router made visible to the client. A steered message answers in plain JSON: there is no new stream, because the events keep riding the original turn's stream, which is already open in another request. A normal message streams, as it has since Part 2. The frontend tells them apart by content type and nothing else.

Two wire details deserve staring at. First, expectedTurnId. The protocol could have accepted a bare "steer whatever's running" and silently dropped the message if nothing was. Instead it demands you name the turn you believe is live, and fails loudly when you're wrong. That requirement is the protocol enforcing the router you'd have needed anyway, and the failure case is the next section's whole subject.

Second, the synthetic note. The steer's acknowledgment carries {"turnId": ...} and nothing else; no notification announces "a steer landed here" on the stream. Verified live: the steered text does resurface, about three seconds later in our raw trace, as a plain userMessage item at the model's next inference boundary, indistinguishable on the wire from any other user message. Which is exactly why the else: branch above injects steer/accepted onto the thread's own queue (the same trick Part 7's approvals played): it becomes a steered event on the stream, at the exact position the steer arrived, and the UI renders the chip. The chip is not decoration. Steer-vs-new-turn is invisible routing, and invisible routing is how users stop trusting an app; the chip makes the difference legible in one glance.

One endpoint, two verbs, and the expectedTurnId precondition refereeing which one is legal at this instant.

Here's the absorbed-steer run from the captures, end to end. A bookshop build starts (four sections, save after each). Six seconds in, with the turn very much alive, a second message goes up: "Also add a footer that says STEERED BY HUMAN." The POST answers {"steered": true} in milliseconds, the chip renders, and the build keeps hammering, same turn, same stream, same receipt at the end, and when that receipt printed, the footer was in the file and in the preview, delivered by the very turn that started without it. In the raw stdio probe of the same flow (a nine-section studio site that time), the steer was acknowledged at t=5.2s, the text resurfaced as a userMessage item at t=8.2s, and the agent's final message listed its work like a proud contractor: hero, about, menu, gallery, and, closing the list, "Footer with STEERED BY HUMAN". One turn, one mid-flight correction, absorbed.

turn/steer, illustrated. The hammering never stops, the door is blue mid-swing, and the meter on the wall kept ticking the whole time. Those meter numbers are this part's real capture, by the way.

Break it on purpose: steer a turn that ended

The router keys off the ledger, and the ledger can lie. Not through a bug; through physics. Here's the probe that proves it: open a build, let a viewer read its stream, then have the viewer stall, reading nothing, while the turn finishes underneath it. The backend's turn/completed handling lives in the stream consumer, and the consumer is stalled with the viewer, so turns.end never runs. One hundred seconds later, the ledger still swears turn 019f3917-8ff9… is live. Now send a message. The router believes the ledger, fires turn/steer, and:

The race, captured raw. Our steer (id 5) and the turn's own completion crossed on the wire; the engine chose the turn, and told us so with a typed error instead of a shrug.

-32600, "no active turn to steer". This is the failure mode the expectedTurnId precondition exists to force, and it's worth appreciating what the protocol is refusing to do here: it will not silently absorb your message into nothing. The worst version of this race, the one users would actually hit, is a message typed in the final second of a build, swallowed without a trace, and a user who now believes steering is unreliable. The precondition converts that into an error you can catch, and the except CodexError branch you already saw converts the error into the obvious fallback: clear the stale ledger line, fall through, start a normal turn. The user's message never bounces; it arrives as the next turn instead of a whisper into the void.

The frontend's job is to keep the routing legible when the fallback fires. steer() sent the message optimistically chipped; if the response turns out to be a stream instead of {"steered": true}, the chip comes off and the stream gets consumed like any other turn's:

frontend/app/page.tsx
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("text/event-stream")) {
setMessages((all) => {
const unchipped = all.map((m, i) =>
i === all.length - 1 && m.role === "user" ? { ...m, steered: false } : m,
);
return [...unchipped, { role: "assistant", blocks: [], status: "working" }];
});
beginStream(Date.now());
setBadges({});
setDiff("");
try {
await consumeStream(res, projectId);
} finally {
endStream();
}
}

In the live race test, the fallback turn streamed 166 events in four seconds and completed cleanly, under a different turn id than the ledger's stale one. And that stale-viewer scenario surfaces one more production wrinkle worth its four lines: the stalled viewer's unread backlog is still sitting in the thread's queue when the next turn's stream starts reading. Without a guard, turn N+1 would replay turn N's abandoned events as its own. Every turn-scoped notification names its turn, so run_turn filters:

backend/app/main.py
while True:
note = await queue.get()
named = ((note.get("params") or {}).get("turnId")
or ((note.get("params") or {}).get("turn") or {}).get("id"))
if named is not None and named != turn_id:
continue

One turn's stream carries one turn's events. Boring sentence, production sentence.

The meter stops rounding to a feeling

Since Part 1, every part has ended runs by quoting token counts, and since Part 6 you've known an uncomfortable fact about where those numbers come from: thread/tokenUsage/updated carries a total that is thread-cumulative (Part 6 watched it hit 1.09M across a few font-hunting turns). This part builds the live gauge, so this is where the accounting finally has to be done right, and the wire makes that harder than it looks. The notification carries two readings, and neither one means "this turn":

One real two-request turn, every number verbatim from the capture. The odometer and the sliver are both true; neither is the fare.

.last is the most recent model request. A build turn makes many of them (every tool call round-trip is another request), and total grows by exactly last on each update, verified across every trace this part produced. So a receipt built on .last under-bills the turn down to one request's sliver, and a receipt built on .total charges the customer for the thread's entire life. The number a receipt owes the user, "what did the work order I signed cost", is on the wire nowhere. It has to be computed, and the computation is a delta of totals, with the baseline falling out of the first update of the turn (that update's last is already this turn's spend, so the pre-turn odometer is its total - last):

backend/app/main.py
if event["type"] == "usage_update":
if baseline is None:
baseline = {k: event["total"].get(k, 0) - event["last"].get(k, 0)
for k in event["total"]}
turn_usage = {k: v - baseline.get(k, 0)
for k, v in event["total"].items()}
event["turn"] = turn_usage
usage_total = event["total"]
turns.record_usage(project_id, {k: v for k, v in event.items()
if k != "type"})
if event["type"] == "complete":
# The receipt: what THIS turn cost (the computed delta).
# The thread's lifetime bill rides along under its true
# name.
event["usage"] = turn_usage
event["thread_total"] = usage_total
await finish_turn(project_id, thread_id, message)

Run the arithmetic on the real capture from the figure, because it's satisfying: first update, last 26,597, total 242,573, so the baseline is 215,976 and the turn so far is 26,597. Second update, last 26,691, total 269,264, turn = 53,288, which is exactly 26,597 + 26,691. Receipt: 53,288 tokens this turn, with the thread's 269,264 riding along under its true name as thread_total. Those are the two usage lines from the Stop section, cashed out.

On the wire, this is the event vocabulary's fifteenth type, usage_update, and the translation is the thinnest in events.py because honesty here means mostly not renaming things:

backend/app/events.py
def usage_event(token_usage: dict) -> dict:
"""thread/tokenUsage/updated, passed through under its true names:
`last` is the most recent model request, `total` is the thread's
lifetime count. The per-turn `turn` reading is computed state, so
main.run_turn attaches it (same shape on the stream and on
GET /usage)."""
return {"type": "usage_update",
"last": token_usage.get("last") or {},
"total": token_usage.get("total") or {},
"context_window": token_usage.get("modelContextWindow")}

The gauge itself is a header button: the big number is this turn (the computed delta), the quieter one is the thread's odometer, labeled as such. Click it for the breakdown of all three readings:

frontend/components/TokenGauge.tsx
export function TokenGauge({ usage }: { usage: UsageReading | null }) {
const [open, setOpen] = useState(false);
if (!usage?.total?.totalTokens) return null;
return (
<div className="relative">
<button
type="button"
data-testid="token-gauge"
onClick={() => setOpen((v) => !v)}
title="Tokens this turn · this thread (click for the breakdown)"
className="flex items-center gap-1.5 rounded-lg border border-stone-200 px-2.5 py-1 font-mono text-xs text-stone-500 hover:border-accent hover:text-accent dark:border-stone-800 dark:text-stone-400"
>
<span data-testid="gauge-turn">{compact((usage.turn ?? usage.last)?.totalTokens)}</span>
<span className="text-stone-300 dark:text-stone-600">·</span>
<span data-testid="gauge-thread" className="text-stone-400 dark:text-stone-500">
{compact(usage.total?.totalTokens)} thread
</span>
</button>

Watch it during a build and the ritual becomes a live instrument: in the e2e run it climbed through eight distinct readings, 15.5k to 163.3k, while the pottery site assembled itself. And the second turn on that thread is the whole lesson in one screenshot: a one-line heading edit whose receipt says 52,641 tokens this turn while the gauge's quieter number says the thread has consumed 216.0k lifetime. Before this part, that receipt would have quoted the 216.0k and quietly taught you that small edits cost a fortune. (52k for one line is still real money at scale, and most of it is cached input, which the breakdown panel itemizes; the cost dials, effort among them, are Part 10's subject.)

The breakdown from the real run: this turn, last request, this thread, all three under their true names. The receipt bills the turn; the odometer keeps the thread.

/usage for the grown-ups

Tokens measure spend; rate limits measure runway. The protocol has a method for the second one, account/rateLimits/read, and Pagewright proxies it at GET /usage/limits for the Part 13 status page to use:

backend/app/main.py
@app.get("/usage/limits")
async def usage_limits():
"""account/rateLimits/read, proxied for the grown-ups: how much of
the account's rate windows this machine has burned. Part 13 puts
this on a status page next to the server's own vitals."""
try:
return await client.request("account/rateLimits/read", {})
except CodexError as exc:
raise HTTPException(status_code=502, detail=str(exc))

And here's the honest capture of what it returns in this series' setup:

TEXT
HTTP/1.1 502 Bad Gateway
content-type: application/json
{"detail":"chatgpt authentication required to read rate limits"}

Not a bug; a boundary. This method is auth-mode dependent: it answers under ChatGPT-plan login, and this series deliberately runs on API-key auth, where usage is billed per token and the "rate limits" it would report don't govern you the same way. Under plan auth, the schema's response is a proper gauge cluster: rateLimits with primary and secondary windows carrying usedPercent, resetsAt, and windowDurationMins, plus credit balances. Pagewright keeps the endpoint, surfaces the 502 truthfully, and treats it as what it is: a status-page tile that lights up for plan-auth deployments and says "not applicable on this box" for key-auth ones. If you take one operational habit from this section, take the token gauge; it's the meter that always answers.

The meter ritual, receipt edition

Every row a real run from building this part, and for the first time in the series, every number is a per-turn delta rather than a thread bill:

RunReceipt
The pottery build, first turn on the thread163,335 tokens this turn · 40s
"Change the hero heading to say Glaze & Kiln."52,641 tokens this turn · 3s (thread odometer: 216.0k)
The footer edit from the wire trace, two model requests53,288 tokens this turn · 2.8s (26,597 + 26,691, exactly)
The bookshop build that absorbed a steer mid-turn43,010 tokens this turn · 112.5s, footer included
The sleepy loop, stopped at section sixstopped by you · 31,065 tokens this turn · 9s

The stopped run's receipt deserves its sentence: 31,065 tokens is what nine seconds of orchestration cost before you pulled the plug, and not a token followed the interrupt, because ending the turn ended the model calls. The shell loop that kept writing afterwards cost nothing; sleep is free, inference isn't. Stop protects exactly the resource the meter measures, which is why they shipped in the same part.

Live control, recorded against the real app: a florist build starts, a mid-turn steer ("make the header background terracotta orange instead") gets the chip and repaints the header inside the same turn, and a second run gets stopped mid-loop, with the red "stopped by you" receipt and the token gauge ticking in the header throughout.

What you built

Part 8
  • The active-turn ledger: turn/interrupt needs {threadId, turnId} and turn/steer needs expectedTurnId as a protocol-enforced precondition, so the backend finally keeps the name that turn/start has been answering with all along, written on begin, erased (by name) on any ending.
  • A Stop button wired to the truth: POST /interrupt sends turn/interrupt, the outcome arrives on the stream as turn/completed status interrupted, and the receipt says stopped by you. Cooperative semantics, verified: the abandoned item never gets item_done, the running shell finishes and its writes land, and the workspace stays the source of truth.
  • Steering: the composer never locks, a mid-turn message routes to turn/steer and is absorbed by the running build (resurfacing as a plain userMessage at the next inference boundary), and a synthetic steered event drives the chip that keeps the routing legible.
  • The race, refereed by the protocol: a steer against a turn that finished fails loudly with -32600 no active turn to steer, and the backend converts that into the fallback the user expects: clear the stale ledger line, start a normal turn, take the chip off. No message is ever eaten.
  • An honest meter: neither tokenUsage field means this turn (.last is one model request, .total is the thread's odometer), so the receipt is a computed delta of totals, the gauge shows turn and thread under their true names, and account/rateLimits/read is a plan-auth-only instrument that this API-key series surfaces truthfully as a 502.

Test yourself

Score ··
01

Why does Part 8's backend suddenly need to remember which turn is live on each project?

02

You click Stop while the agent's shell loop is mid-run. Which of these is TRUE, per the live captures?

03

A message arrives, the ledger says a turn is active, but the turn actually finished a beat earlier. What happens?

04

thread/tokenUsage/updated carries .last and .total. What number belongs on a per-turn receipt?

05

GET /usage/limits returns 502 with 'chatgpt authentication required to read rate limits'. Why?

Commit it, from the project root:

BASH
git add backend frontend
git commit -m "part 8: stop, steer, and the meter"

Pagewright now takes orders mid-swing, stops when told (and is honest about what stopping means), and bills by the work order instead of the lifetime. But every one of those powers rides a single fragile pipe: one SSE response, held open by one browser tab. Refresh mid-build and the stream is gone. The build isn't. Next: durability.

The complete, tested code for this part lives in part-08-stop-steer-usage in the companion repo. Code blocks with a GitHub icon link straight to the exact file; "View full file" shows the whole file in place with this section's lines highlighted.