Building on the Codex app server

codex app-server is the Codex agent behind a JSON-RPC interface. You start the process, send it requests ("start a thread", "start a turn"), and it streams back everything the agent does: reasoning, commands, file changes, questions. OpenAI documents it in the Codex developer docs and explained how it was built in Unlocking the Codex harness.

We built Orage on it: a web app and a native iPhone app that drive a real Codex running on a computer in the cloud. These are the notes we wish we had on day one. They hold for Codex 0.159.3, the version we pin.

Start it

Two ways to listen:

# One JSON object per line on stdin and stdout
codex app-server --listen stdio://

# A WebSocket, for a client on another machine
codex app-server --listen ws://0.0.0.0:4500 \
  --ws-auth capability-token --ws-token-file /path/to/token

Over stdio, each line is one JSON-RPC 2.0 object with the jsonrpc field left out. Stderr is for diagnostics only. Over WebSocket, the client sends Authorization: Bearer <token> on the handshake, then one JSON object per text frame. Error -32001 means the server is overloaded: retry with backoff.

The handshake

Nothing is accepted before initialize and then the initialized notification:

{"id":1,"method":"initialize","params":{"clientInfo":{"name":"my-client","title":null,"version":"0.1.0"},"capabilities":{"experimentalApi":true}}}
{"method":"initialized"}

After that, model/list works even before you sign in, and account/read tells you whether you need to.

Get the types instead of guessing

The CLI writes TypeScript types for the whole protocol:

codex app-server generate-ts --experimental --out ./generated

We regenerate them on every Codex upgrade and turn the same output into Swift for the iPhone app, so both clients break at compile time when the protocol changes, not in front of a user. One small fix: the generated files import each other without a file extension, so for Node's type stripping we add .ts to every relative import.

Signing in from a server

A cloud container has no browser. account/login/start with type chatgptDeviceCode gives you a code to type on any device, and the login lands in the container. Your ChatGPT plan then pays for the model; no API key needed.

The server asks questions too

The app server sends requests to the client, not only notifications. Your client must answer each one with { id, result }, or the turn waits forever:

  • item/commandExecution/requestApproval and item/fileChange/requestApproval
  • item/permissions/requestApproval
  • item/tool/requestUserInput, when the agent asks you something
  • mcpServer/elicitation/request
  • account/chatgptAuthTokens/refresh

One Codex, many screens

A phone, a laptop tab and an iPad should all show the same thread live, and any of them should be able to approve a command. Orage keeps one connection to each app server and puts a small bridge between it and every screen, so ids, approvals and answers are handled in one place:

Problem What the bridge does
Two tabs both send request id 1 Rewrites each client's ids on the way in, and back on the way out
A server request id could collide with a client id Gives server requests their own ids, srv:1, srv:2
Three screens show the same approval Sends it to all of them; the first answer wins and the others get serverRequest/resolved
The phone was asleep when the approval came Re-sends every open approval to a screen that connects later
Every tab calls initialize Answers it from the bridge's own handshake, so the app server sees one client

Notifications simply fan out to every connected screen.

What bit us

A thread you only read is not loaded. After thread/read, the thread stays notLoaded and the next turn/start fails with -32600 thread not found. Call thread/resume before a follow-up on such a thread, and retry once on that error.

Send the sandbox policy with every turn. A thread resumed after an app server restart can fall back to its default sandbox, even if it started with full access. The approval policy alone does not grant file access: never can still run inside a sandbox.

The same question can come back with a new id. When a client resumes a thread, Codex sends the waiting request again, sometimes under a different id. Treat same method and same params as the same question, and answer every id Codex used.

Close what the turn closed. When turn/completed arrives, resolve the requests still open for that turn, so no prompt outlives it. An interrupted turn resolves its own requests; do not answer them twice.

Browsers cannot read a refused WebSocket upgrade. If your bridge checks a cookie, accept the upgrade and close the socket with your own code (we use 4401) so the web app knows to send the person to sign in.

Remote computers

The same protocol runs over the WebSocket listener on another machine. Orage gives each task a cloud computer running codex app-server --listen ws://, and routes every request that carries a threadId to that thread's computer. The screens never need to know where a thread lives.

Test without a model

We keep a fake app server, a short script that answers the methods the apps use and replays a scripted turn: reasoning, a plan, a command that needs approval, a file change, a two-question prompt, markdown, token usage. The server and web tests run against it in seconds; a separate test drives the real Codex binary.

Questions

What is the Codex app server? The Codex agent as a long-running process that speaks JSON-RPC, so another program (an editor, a desktop app, your own client) can drive it.

Do I need an API key? No. Sign in with ChatGPT through the protocol and your plan pays for the model. An API key works too.

Can it run on a server? Yes. Use the WebSocket listener with a capability token, or stdio behind your own server, and sign in with the device code.

Can many screens share one app server? Orage does it with one connection and a bridge that rewrites ids, fans out notifications and lets the first answer to an approval win, as above.

Updated 2026-10-09