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

· 34 min read

Codex App Server in Production, Part 7: Approvals: The Foreman's Stamp

The protocol turns around: the engine sends your backend a numbered JSON-RPC request and freezes mid-turn until somebody answers it. A Future parks in a registry, the approval card shows the actual patch, and when nobody clicks, the clock says no for you.

codex · openai · fastapi · nextjs · tutorial

Flip one policy string and run a turn. That's the entire setup for this part's opening mystery. Standard mode's approval dial goes from "never" to "on-request", meaning the agent may now ask permission to step past Part 6's walls. So I asked for the one thing Standard always refuses, a curl to the live network, and watched the agent get rejected. Instantly. By nobody. No prompt appeared, no human clicked anything, and the agent shrugged: "I couldn't run it because the escalated network request was rejected."

Something inside Pagewright is answering questions we never see. Finding that something, then replacing it with a real human holding a real stamp, is this whole part. By the end of this page, risky commands and out-of-bounds patches pause mid-turn on an amber card in the chat, with the agent's own stated reason and, for file changes, the actual diff, and the build continues the moment you click Approve or Deny. Walk away instead, and a clock answers for you, honestly.

The end of this part, from the real run. The turn is genuinely paused: the engine is awaiting a JSON-RPC response, the backend is awaiting a Future, and the Future is awaiting your click.

That card is the most protocol-dense UI element in the series so far. Behind it sits the mechanic this part exists to teach: for approvals, the app server sends us a JSON-RPC request. It numbers its own note, slides it under the door, and stops. Every id on the wire so far has been ours; this one is the engine's, and the reply is owed by us. The sibling series met the same product problem through an SDK callback; here you'll see it raw, as a message direction reversing on a pipe.

Two lines, and a phantom foreman

The change that starts everything is embarrassingly small. approval_policy() joins sandbox_policy() in main.py: Read-only and Trusted keep "never" (for opposite reasons, and the grid section will cash that phrase out), while Standard now returns "on-request". Both dials ride every turn/start, so the mode picker from Part 6 needs no new wiring at all:

backend/app/main.py
"sandboxPolicy": sandbox_policy(mode, workspace),
"approvalPolicy": approval_policy(mode),
# Without this the model reasons silently and the drawer stays
# empty. Part 10 turns summary (and effort) into user-facing dials.
"summary": "detailed",
})

The mapping itself is one expression, return "on-request" if mode == "standard" else "never", and per the approval-policy enum it's verified live that the per-turn value overrides the thread's "never" baseline. Run the curl request now and you get the mystery from the opening: a rejection with no rejector. Here's the same moment captured raw over stdio, in a probe that answers exactly the way Part 2's client does:

TEXT
← {"id": 0, "method": "item/commandExecution/requestApproval",
"params": {..., "command": "/bin/zsh -lc 'curl -sI https://example.com | head -3'"}}
→ {"jsonrpc": "2.0", "id": 0, "result": {}}
← {"method": "item/completed", "params": {"item": {"type": "commandExecution",
"status": "declined", "exitCode": null, ...}}}

The probe answered the way Part 2's reader answers any server request it has no handler for: the polite empty reply. And the engine, receiving an answer with no decision in it, treated the question as settled: the command item completed with status "declined", no shell ever ran, and the agent adapted to a refusal nobody made. Five seconds, turn complete, everyone confidently wrong.

That polite empty reply was the right call in Part 2, because the alternative is worse. I ran that version too: a probe that receives the approval request and answers nothing at all. For 100 seconds (until I killed it), the engine sent zero further messages. No retry, no nudge, no timeout of its own. The item stays frozen and the turn holds its breath, indefinitely. Part 2 chose "answer something over deadlock", and that kept four parts of code deadlock-free. But once the policy invites real questions, "answer something" becomes a phantom foreman stamping DENY on every work order before you ever see it.

So the fix has a precise shape. Not "make the backend stop hanging" (it never hung), but: route the engine's question to a human, hold the JSON-RPC response open while they think, and answer with their words.

The request that turns the desk around

First, look hard at the thing we're routing, because it breaks a pattern four parts old. Part 1's stream anatomy taught three message shapes: our requests (we number them), the engine's responses (they quote our number), and notifications (unnumbered narration). Part 2's reader compiled that grammar into a three-way switch, plus one case that had never fired in anger: a message with an id and a method. The engine asking us.

The fourth shape, drawn from a real capture. The engine numbers its note, and the numbered-notes discipline from Part 1 now works in reverse: exactly one reply must quote id 0, and it comes from us.

Here's a real one on the wire, with the response our finished backend sends. Two details deserve staring at:

A real requestApproval and its answer. Note the reason field: the engine argues its case in a full sentence. And note availableDecisions, which omits two decisions the server demonstrably honors.

First, the reason. The engine doesn't send a bare "may I?"; it sends an argument: "Do you want to allow this network command to run outside the sandbox so I can execute it exactly as requested?" That sentence was written by the model, for the human, and it's free UI copy of a quality most permission dialogs never reach. Our card renders it verbatim.

Second, availableDecisions, which looks like the authoritative menu of legal answers and is not. In 0.142.4 the wire lists only accept, an execpolicy-amendment variant, and cancel. Yet the generated schema's decision enums declare accept | acceptForSession | decline | cancel, and both missing decisions work live: decline a command and its item completes with status "declined"; accept for session and the re-ask disappears. When the wire and the schema disagree, the schema is the vocabulary and the wire field is a hint. Pagewright validates against the schema enum and says so in a comment right where the next maintainer will look.

The bridge: a Future in the foreman's inbox

Between "the handler receives params" and "the handler returns a decision" sits an arbitrary amount of human time. The tool for suspending a coroutine until some other code decides to wake it is an asyncio.Future, and if you want that pattern built from zero, with its failure modes hunted down one by one, the sibling series teaches it as a full chapter. Here it's one page, because the transport is the new material. Each pending question becomes a PendingApproval in a registry, in the new app/approvals.py:

backend/app/approvals.py
class PendingApproval:
"""One unanswered question. The Future is the mailbox: the JSON-RPC
handler awaits it, the decision endpoint fills it."""
def __init__(self, kind: str, thread_id: str, item_id: str) -> None:
self.id = uuid.uuid4().hex[:8]
self.kind = kind # "command" | "file_change"
self.thread_id = thread_id
self.item_id = item_id
self.timeout = timeout_seconds()
self.expires_at_ms = int((time.time() + self.timeout) * 1000)
self.future: asyncio.Future = asyncio.get_running_loop().create_future()

The registry's ask() function runs the whole life of a question: file the PendingApproval, announce it as a synthetic approval/requested note on the thread's own notification queue (so the card rides the same SSE stream as everything else and lands in the chat at the exact position the turn paused), then wait. The waiting half is where the product decision lives:

backend/app/approvals.py
try:
decision = await asyncio.wait_for(pending.future, pending.timeout)
reason = "user"
except asyncio.TimeoutError:
decision, reason = "decline", "timeout"
finally:
_registry.pop(pending.id, None)
await notify({"method": "approval/resolved", "params": {
"approval_id": pending.id,
"decision": decision,
"reason": reason,
"resolved_at_ms": int(time.time() * 1000),
}})
return {"decision": decision}

Read the except branch as a policy statement, because that's what it is. You proved above that the engine will wait forever; a hosted product must not, because "forever" is a work order held open, an engine slot occupied, and a turn nobody can finish, all because a human closed a tab. So Pagewright decides, explicitly: an unanswered approval declines itself after PAGEWRIGHT_APPROVAL_TIMEOUT seconds (ten minutes by default), and the wire says so honestly, with reason: "timeout" rather than a forged human "no". The clock is a member of the team, and its decisions are labeled as its own.

Wiring the bridge into the app is the moment Part 2's most deliberate design finally pays out. The seam that has sat empty for five parts gets handlers:

backend/app/main.py
@asynccontextmanager
async def lifespan(app: FastAPI):
for entry in projects.load_registry():
mount_preview(app, entry["id"])
# The Part 2 seam, finally used: server-initiated requests get
# handlers instead of the polite empty reply.
client.on_server_request("item/commandExecution/requestApproval",
approval_handler("command"))
client.on_server_request("item/fileChange/requestApproval",
approval_handler("file_change"))
await client.start()
yield

approval_handler(kind) builds a tiny closure: grab the thread's queue as the notify channel, then return await approvals.ask(kind, params, notify). Because the dispatcher awaits the handler and ask() awaits the Future, returning from the handler is literally answering the JSON-RPC request: the value flows back through CodexClient._respond and down stdio with the engine's own id on it. No new transport code was written for this part. The seam absorbed its first real tenant without modification, exactly as designed in Part 2, and Parts 10 and 11 will move in the same way.

The human's half is one endpoint. Note which failures it refuses to paper over:

backend/app/main.py
entry = projects.get_project(project_id)
if entry is None:
raise HTTPException(status_code=404, detail="no such project")
if req.decision not in approvals.DECISIONS:
raise HTTPException(
status_code=400,
detail=f"decision must be one of {list(approvals.DECISIONS)}")
pending = approvals.get(approval_id)
if pending is None or pending.thread_id != entry.get("thread_id"):
raise HTTPException(status_code=404,
detail="no such pending approval for this project")
if not approvals.resolve(approval_id, req.decision):
raise HTTPException(status_code=404, detail="approval already resolved")
return {"approval_id": approval_id, "decision": req.decision}

The last branch is the double-answer guard. Click Approve in two tabs, or click after the timeout already declined for you, and the second answer gets an honest 404: the question is gone. A stale card must find out it's stale, not pretend it won. And notice what the endpoint doesn't care about: which browser, which tab, which viewer. Any client that can POST can answer, because the Future doesn't know who filled it. Part 9's multi-tab work will collect that dividend without touching this code.

One question's life, both endings, timed from real runs. Either way the engine gets exactly one response quoting its id, and either way the turn continues.

Checkpoint. Right now you have: a backend where the engine's questions become parked Futures, an SSE event announcing each one, and an endpoint that resolves them. What you don't have is anything to click. Time to build the window the queue forms at.

The card at the window

You know the reflex. A dialog appears mid-task and your cursor is moving toward the confirm button before your eyes have moved at all, because a decade of cookie banners and "unlock Keychain" prompts trained you to treat questions as friction instead of information. Approval UIs die by that reflex, and they earn it by asking often and showing little. Pagewright's card fights back with specifics: the engine's own reason as the headline, the exact command in mono, the directory it would run in, and, for patches, the diff itself. A question that shows its work invites an answer that means something.

On the frontend, an approval is its own block type, appended at arrival position (exactly where the turn froze) and patched in place when the answer comes back. The reducer in applyEvent needs two new cases; here's the first:

frontend/app/page.tsx
if (event.type === "approval_request") {
return [
...blocks,
{
type: "approval",
id: event.approval_id,
kind: event.kind,
command: event.command,
cwd: event.cwd,
reason: event.reason,
files: event.files ?? [],
diff: event.diff ?? "",
availableDecisions: event.available_decisions,
expiresAtMs: event.expires_at_ms,
},
];
}

The approval_resolved case maps over the blocks and attaches resolved: {decision, reason, atMs} to the matching card, which swaps its buttons for an outcome line. Those are the event vocabulary's thirteenth and fourteenth types, the first additions since Part 5, and, for the fourth part running, every existing consumer kept working unmodified.

The card itself (ApprovalCard.tsx) turns the decision enum into buttons, and one omission there is deliberate enough to quote:

frontend/components/ApprovalCard.tsx
// Wire decisions → button labels. `cancel` (deny + interrupt the turn)
// stays off the card: the Stop button already owns "abandon the turn",
// and two red buttons with different blast radii is how mistakes happen.
const BUTTONS: { decision: string; label: string; deny?: boolean }[] = [
{ decision: "accept", label: "Approve" },
{ decision: "acceptForSession", label: "Approve for session" },
{ decision: "decline", label: "Deny", deny: true },
];

cancel is a legal decision (deny and interrupt the whole turn), and the card refuses to offer it, because "Deny" (no, but keep working) and "Deny and kill everything" differ by a blast radius, and adjacent red buttons with different blast radii are how Friday evenings go wrong. Stop already owns turn-killing; one verb per button.

Clicking calls decide(), and its most important property is a thing it doesn't do:

frontend/app/page.tsx
const decide = useCallback(
async (approvalId: string, decision: string) => {
if (!activeId) return;
try {
const res = await fetch(
`${API_BASE}/projects/${activeId}/approvals/${approvalId}/decision`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision }),
},
);
if (!res.ok) throw new Error(`The server said ${res.status}.`);
} catch {
setToast("Could not send the decision. Is the backend running?");
}
},
[activeId],
);

No optimistic flip. The card doesn't paint itself "Approved" on click; it disables its buttons and waits for approval_resolved to come down the stream, because the stream is the single source of truth about what was decided, and an optimistic UI plus a 404'd double-answer equals a card lying about history. The composer stays usable while a question hangs (type your next thought freely), with an amber chip under it explaining why nothing new is running yet. In the real approve run, the whole exchange settled fast: question up, human click, HTTP/2 200 reported back, 19.0 seconds and 30,928 tokens for the turn.

The dry-cleaning ticket

Now the second request type, and the part's best protocol puzzle. Ask for a patch outside the walls ("create ~/pagewright-fc-test.txt with two lines, using apply_patch") and item/fileChange/requestApproval arrives. Its params, in full: threadId, turnId, itemId, startedAtMs, reason: null, grantRoot: null. Read that twice. No diff. No file list. Not even the availableDecisions hint. The question the human most needs pictured, "approve what, exactly?", arrives with no picture.

The picture exists; it went past you moments earlier. Watch the wire:

Same millisecond, two notes. The item announces itself carrying the patch; the question that follows names the item and nothing else. In every captured trace the item arrived first.

The fileChange item's own item/started carries the complete changes array, diffs included, and it arrived before the question in every trace captured for this part, same itemId on both. So the backend keeps a one-turn join table: remember each fileChange item as it opens, and when a file-change approval names an itemId, look the patch up and enrich the SSE event before it reaches the browser:

backend/app/main.py
if event["type"] == "item_start" and event["kind"] == "fileChange":
file_changes[event["item_id"]] = note["params"].get("item", {})
if event["type"] == "approval_request" and event["kind"] == "file_change":
item = file_changes.get(event["item_id"], {})
event["files"], event["diff"] = approval_patch(item, workspace)

approval_patch() in events.py does the dressing-up: workspace-relative paths where possible (absolute paths kept loudly visible when the target is outside the workspace, which for this card is the whole point), and a synthesized diff --git header per file so Part 4's diff renderer can be reused inside the card unchanged. One wrinkle from the traces: update diffs arrive as ready-made @@ hunks, but add diffs arrive as the raw new file content, so new files get wrapped as a single insert hunk:

backend/app/events.py
files, parts = [], []
for change in item.get("changes", []):
raw = change.get("path", "")
try:
path = Path(raw).relative_to(workspace).as_posix()
except ValueError:
path = raw # outside the workspace: keep it absolute and visible
kind = change.get("kind", {}).get("type", "")
files.append({"path": path, "kind": kind})
diff = change.get("diff", "")
if kind == "add":
lines = diff.splitlines()
diff = "\n".join([f"@@ -0,0 +1,{len(lines)} @@",
*("+" + line for line in lines)])
header = path.lstrip("/") # a//tmp/x reads worse than a/tmp/x
parts.append(f"diff --git a/{header} b/{header}\n{diff}")
return files, "\n".join(parts)

The result is the dry-cleaning counter this part is named for: the ticket says what's on the hanger, and you inspect the garment before it's hung.

A patch inspected before it lands. The request carried none of this; the diff and the path came from the join table, matched by itemId.

I clicked Approve on that card, and the two lines landed at the target path, verbatim: approved by the foreman, stamped. Sit with where that file is: outside writableRoots. The sandbox that spent all of Part 6 refusing exactly this write stood aside, because your accept escalated the action past the walls. An approval is not a speed bump in front of the sandbox; it's a gate through it. That's why the card shows paths outside the workspace in full and ugly, why "Approve for session" deserves a beat of thought before it becomes your reflex, and why the timeout defaults to declining. When this button is wrong, the kernel will not save you, because you overruled it.

Decisions, and the menu that understates itself

Four decisions, verified against the generated schema and exercised live: accept, acceptForSession, decline, cancel. You've seen why the wire's availableDecisions can't be trusted as the menu (it omits two answers the server honors), and why cancel stays off the card. That leaves the interesting one.

acceptForSession is scoped to the exact command string. Approve curl -sI https://example.com | head -3 for the session and the identical command re-runs next turn with no card at all; the real second turn opened with "I'm rerunning the exact same pipeline" and finished in 7.1 seconds, zero questions asked. But the cache key is the string, not the intent: in the probe traces, blessing git init . for the session did nothing for git init, which asked again on the very next turn. One dot of difference, fresh question. As a user this occasionally feels dim; as a security posture it's exactly right, because "stop asking about things like this" is a similarity judgment no one should let a cache make. The button's honest reading is "stop asking about this, today, in this thread", and that's how the part teaches it.

The foreman's window. A patch that shows its ticket gets the stamp; the request that explains nothing gets the coffee.

Say no and watch it adapt

Approving is the boring path. The quality of an approval system shows when you refuse, so refuse: ask the agent to save notes to ~/pagewright-notes.txt (outside the workspace), and when the card comes up, click Deny. What happened next in the real run is the best free lesson the engine has given this series.

First, the ask itself came pre-negotiated. Before requesting anything, the agent announced its plan and its fallback: "If you deny it, I'll write the same notes inside the workspace instead and tell you the path." Then the card, then my Deny, and then, with no fuss: "The home-directory write was denied, so I'm saving the same notes in the workspace instead and will point you to that file." One in-workspace command later (exit code 0, no card, the bench allows it), the notes existed at a path it linked me to. Turn complete, 12.7 seconds. decline means "no, but keep working", and the model treats a denial as information to route around, not as a failure to sulk over. Write your own reason strings that well and your users will forgive a lot.

The denial, resolved. Spot the contradiction: the badge above the card settled with a checkmark, and the red line below it is the one telling the truth.

That screenshot contains a wrinkle you should meet here rather than in production. A denied fileChange item still fires item/completed, so the generic badge from Part 3, which styles by item completion, settles with its usual done state even though no file landed (denied commands are cleaner: their item completes with status "declined"). The protocol isn't lying, an item's lifecycle did complete; it's answering "did this item finish?" when the user is asking "did this change happen?". The card is the component that answers the real question, which is why it renders the outcome in red at the exact spot the decision was made and why it never gets removed from the transcript. When a badge and a card disagree, believe the card.

Now the second refusal, the one nobody performs. Set PAGEWRIGHT_APPROVAL_TIMEOUT=20, ask for the network command, and let the card rot. At exactly +20 seconds, the wire speaks:

TEXT
data: {"type": "approval_resolved", "approval_id": "3e72f758",
"decision": "decline", "reason": "timeout",
"resolved_at_ms": 1783366690929}

The Future never resolved, asyncio.wait_for raised, and the clock's decline went down stdio quoting the engine's id. The card flips to "Denied automatically (nobody answered in time)", a red countdown having warned about it once fewer than two minutes remained, and the turn continued: the agent adapted the same way it does to a human "no" and completed at 25.8 seconds. Rerun it and answer normally, then try to answer again with a second curl: 404, "approval already resolved". Every escape hatch from a parked Future exists and is labeled.

The grid completes

Part 6 drew a two-axis grid and left one axis unwired, with a dashed arrow labeled "Part 7 moves Standard's consent dial here". Promise kept:

Both dials wired. Standard changed columns without changing rows: same bench, same walls, and now a foreman for anything that wants past them.

Say the three postures out loud once, because the symmetry is the retention device. Read-only never asks because it never acts. Standard asks, and only about escalations. Trusted never asks because the sandbox contains: the walls hold everything that matters, so questions would be noise. Two "never"s, opposite reasons, and between them a mode where the human is in the loop exactly at the boundary and nowhere else.

The meter ritual, question-shaped

Every row a real run from building this part:

RunWhat happenedReceipt
Network curl, approved from the cardcommand ran outside the walls, reported HTTP/2 20030,928 tokens · 19.0s
Home-directory notes, deniedagent adapted, wrote the notes in the workspace instead47,987 tokens · 12.7s
Same curl, "Approve for session"ran after one click30,971 tokens · 4.7s
The identical command, next turnzero cards, "rerunning the exact same pipeline"62,385 tokens · 7.1s
Out-of-workspace patch, approvedtwo lines landed outside writableRoots47,464 tokens · 6.3s
Card left to rot (20s timeout)clock declined with reason: "timeout", turn completed anyway31,168 tokens · 25.8s

One number deserves its own sentence: the frozen minutes cost nothing. While a card hangs, the engine sends no events, calls no model, and burns no tokens; the receipts above are all work, no waiting, and the timeout run's 25.8 seconds contains 20 seconds of absolute, free silence. Human deliberation is the cheapest resource on this page. (The totals are the thread-cumulative figures from thread/tokenUsage/updated, as always since Part 6; each row here is a fresh test project, so the first turn of each reads as its own cost.)

Two real approvals, recorded live: a Standard-mode network command pausing on the amber card (the reason is the engine's own sentence) and continuing the moment Approve is clicked, then an out-of-workspace patch showing its full diff on the card and getting denied, with the agent adapting out loud instead of failing.

What you built

Part 7
  • The reversed request, handled: item/commandExecution/requestApproval and item/fileChange/requestApproval arrive as server-to-client JSON-RPC requests (an id, a demanded response, a frozen item), and Part 2's empty-reply seam now routes them to real handlers, ending the phantom foreman that silently declined every escalation.
  • The Future bridge, Codex-shaped: each question parks an asyncio.Future in a registry, rides the SSE stream as approval_request at the exact position the turn paused, and POST /approvals/{id}/decision resolves it, with returning from the handler literally answering the engine.
  • A timeout as a named product decision: unanswered approvals auto-decline after PAGEWRIGHT_APPROVAL_TIMEOUT with reason "timeout" on the wire, because the engine itself will verifiably wait forever, and double answers get an honest 404.
  • The join-table patch card: fileChange approval requests carry no diff and no file list, so the backend joins them by itemId to the patch that arrived on item/started, and Part 4's diff renderer shows the ticket before it's hung.
  • Decisions grounded in the schema, not the wire: accept, acceptForSession (exact-command-scoped, thread-wide), decline (the agent adapts, with narration), and cancel (kept off the card), plus the escalation truth: an accepted action lands OUTSIDE the sandbox walls.

Test yourself

Score ··
01

On the wire, what distinguishes an approval request from every notification the stream has carried since Part 1?

02

A fileChange approval request arrives. Where does the diff shown on the approval card come from?

03

The request's availableDecisions field lists only accept, an execpolicy amendment, and cancel. What should your backend accept as legal decisions?

04

You click "Approve for session" on git init .. Next turn, the agent wants to run git init. What happens?

05

Nobody answers an approval card. What does the finished Part 7 backend do, and why?

Commit it, from the project root:

BASH
git add backend frontend
git commit -m "part 7: approvals - the foreman's stamp"

The foreman's window is open: the builder works free inside the walls, asks before stepping past them, shows you the ticket before anything is hung, and takes no for an answer gracefully. But every question and every answer here happens before the action. Once a turn is running, you still have no way to redirect it, and the only lever you've ever had mid-flight is the one this part deliberately kept off the card. You can say no before it acts. Next: changing its mind mid-flight, with a Stop button that works and a turn/steer that doesn't even make it stop hammering.

The complete, tested code for this part lives in part-07-approvals 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.