This document captures the actual wire protocol used between Symphony Elixir and a Codex app-server subprocess, based on source and tests (not assumptions).
Primary references:
- Codex app-server README and Rust transport/protocol code
- Symphony Elixir
lib/symphony_elixir/codex/app_server.ex - Symphony Elixir
test/symphony_elixir/app_server_test.exs - OpenAI blog post: "Unlocking the Codex harness"
- Protocol shape: JSON-RPC-like request/response/notification objects.
- JSON-RPC variant: "JSON-RPC lite" (omits
"jsonrpc": "2.0"on the wire). - Default transport: stdio (
--listen stdio://). - Framing: newline-delimited JSON (JSONL / NDJSON-style line framing).
Codex side evidence:
codex-rs/app-server/src/transport.rs- stdin reader uses
BufReader::lines()and processes one line == one JSON message. - stdout writer serializes JSON and appends
"\n".
- stdin reader uses
Symphony side evidence:
elixir/lib/symphony_elixir/codex/app_server.exsend_message/2writesJason.encode!(message) <> "\n"to port.- read loop uses port line mode and handles
{:eol, chunk}/{:noeol, chunk}. - partial chunks are buffered until newline before JSON decode.
Test evidence:
elixir/test/symphony_elixir/app_server_test.exs- "buffers partial JSON lines until newline terminator" verifies split/large line behavior.
Conclusion:
- Not Content-Length framing. Not LSP headers.
- Actual framing is line-delimited JSON over stdio.
Wire-level object forms (from jsonrpc_lite.rs and runtime behavior):
- Request (expects response)
- Response (successful)
- Error response
- Notification (no response)
Examples:
{"id":1,"method":"initialize","params":{"clientInfo":{"name":"symphony-orchestrator","title":"Symphony Orchestrator","version":"0.1.0"},"capabilities":{"experimentalApi":true}}}{"id":1,"result":{"userAgent":"..."}}{"id":7,"error":{"code":-32001,"message":"Server overloaded; retry later."}}{"method":"turn/completed"}Symphony startup sequence (AppServer.start_session/1, do_start_session/3):
- Spawn app-server subprocess.
- Send
initializerequest (id=1). - Wait for response with matching
id=1. - Send
initializednotification. - Send
thread/startrequest (id=2). - Extract
thread.idfrom response. - On each run, send
turn/startrequest (id=3) with samethreadId. - Stream notifications until terminal event (
turn/completed,turn/failed,turn/cancelled).
{
"method": "initialize",
"id": 1,
"params": {
"capabilities": { "experimentalApi": true },
"clientInfo": {
"name": "symphony-orchestrator",
"title": "Symphony Orchestrator",
"version": "0.1.0"
}
}
}{"method":"initialized","params":{}}{
"method": "thread/start",
"id": 2,
"params": {
"approvalPolicy": "never | on-request | ...",
"sandbox": "read-only | workspace-write | danger-full-access | object variant",
"cwd": "/abs/workspace/path",
"dynamicTools": [
{
"name": "linear_graphql",
"description": "...",
"inputSchema": { "type": "object", "required": ["query"] }
}
]
}
}{"id":2,"result":{"thread":{"id":"thread-123"}}}{
"method": "turn/start",
"id": 3,
"params": {
"threadId": "thread-123",
"input": [{ "type": "text", "text": "<prompt>" }],
"cwd": "/abs/workspace/path",
"title": "MT-123: Issue title",
"approvalPolicy": "...",
"sandboxPolicy": { "type": "workspaceWrite", "networkAccess": false }
}
}{"id":3,"result":{"turn":{"id":"turn-456"}}}During turn streaming, Symphony handles these method families:
turn/completed-> success terminal stateturn/failed-> returns{:error, {:turn_failed, params}}turn/cancelled-> returns{:error, {:turn_cancelled, params}}
Examples:
{"method":"turn/completed"}{"method":"turn/failed","params":{"message":"..."}}{"method":"turn/cancelled","params":{"reason":"..."}}item/commandExecution/requestApprovalitem/fileChange/requestApprovalexecCommandApprovalapplyPatchApprovalitem/tool/requestUserInput
Symphony reply pattern:
- auto-approve (when policy effectively allows):
{"id":99,"result":{"decision":"acceptForSession"}}or
{"id":99,"result":{"decision":"approved_for_session"}}- for tool user-input approvals:
{"id":110,"result":{"answers":{"mcp_tool_call_approval_call-717":{"answers":["Approve this Session"]}}}}- non-interactive fallback answer:
{"id":111,"result":{"answers":{"freeform-718":{"answers":["This is a non-interactive session. Operator input is unavailable."]}}}}item/tool/call-> Symphony executes local tool executor and returns result in response to sameid.
Success example from tests:
{"id":102,"result":{"success":true,"contentItems":[{"type":"inputText","text":"{\"data\":{\"viewer\":{\"id\":\"usr_123\"}}}"}]}}Failure/unsupported pattern:
{"id":101,"result":{"success":false,"contentItems":[{"type":"inputText","text":"Unsupported dynamic tool ..."}]}}- Any unrecognized
methodis emitted as generic notification and processing continues. - Non-JSON lines are logged and classified as malformed stream lines; loop continues.
- Symphony creates one thread via
thread/startand storesthread_idin session. - Each new prompt in same session uses new
turn/startwith the samethreadId. - Session id in Symphony observability is composed as
<thread_id>-<turn_id>.
So continuation is:
- same thread, multiple turns (not a new thread per turn).
- Timeout waiting for a specific response id ->
:response_timeout. - Port exits while waiting ->
{:port_exit, status}. - Matching-id response with
error->{:response_error, error}.
- No event before turn timeout ->
:turn_timeout. turn/failed->{:turn_failed, params}.turn/cancelled->{:turn_cancelled, params}.- input-required variants ->
{:turn_input_required, payload}. - approval required with safer policy ->
{:approval_required, payload}.
- Non-JSON lines are tolerated; logged; loop continues.
- stderr merged into stdout (
:stderr_to_stdout) and treated as stream lines.
- If ingress queue saturates, app-server can return JSON-RPC error:
- code
-32001 - message
"Server overloaded; retry later."
- code
Symphony Client Codex app-server
| |
| -- initialize(id=1, clientInfo, caps) --> |
| <-- result(id=1, userAgent/...) --------- |
| -- initialized(notification) -----------> |
| -- thread/start(id=2, cwd, policy, ...) ->|
| <-- result(id=2, thread.id) ------------- |
| -- turn/start(id=3, threadId, input, ..)->|
| <-- result(id=3, turn.id) --------------- |
| <-- item/... notifications (stream) ----- |
| <-- turn/completed ---------------------- |
| |
Symphony Client Codex app-server
| |
| -- turn/start(id=3, ...) --------------> |
| <-- result(id=3, turn.id) -------------- |
| <-- item/commandExecution/requestApproval(id=99)
| -- result(id=99, decision=acceptForSession) --> (if auto-approve)
| OR
| (if not auto-approve) return approval_required error and stop
| |
| <-- turn/failed or turn/cancelled ------- |
| -> return structured error |
initializerequest/response: required first; theninitializednotification.thread/startresponse: extractsthread.id, errors if payload shape invalid.turn/startresponse: extractsturn.id.turn/completed: success return.turn/failed: error return.turn/cancelled: error return.item/tool/call: executeDynamicTool, respond withresultpayload.- approval requests: auto-approve only when configured (
approvalPolicy == "never"in current policy derivation), else fail fast requiring approval. - malformed line: log + continue.
- You must treat the stream as line framed; buffering until newline is mandatory.
- Request ids can be integer or string; preserve id type in replies.
- Do not require
"jsonrpc":"2.0"; Codex intentionally omits it. - For robust clients: handle out-of-order notifications while waiting for a specific response id.
- OpenAI blog: https://openai.com/index/unlocking-the-codex-harness/
- Codex app-server README: https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/README.md
- Codex transport implementation: https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/src/transport.rs
- Codex JSON-RPC lite types: https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/jsonrpc_lite.rs
- Codex message processor init guard: https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/src/message_processor.rs
- Symphony Elixir app-server client: https://raw.githubusercontent.com/openai/symphony/main/elixir/lib/symphony_elixir/codex/app_server.ex
- Symphony Elixir app-server tests: https://raw.githubusercontent.com/openai/symphony/main/elixir/test/symphony_elixir/app_server_test.exs