A typed Rust interface for the OpenAI Codex CLI app-server JSON-RPC protocol.
Part of the rust-code-agent-sdks workspace.
This crate provides type-safe Rust representations of the Codex CLI's JSON-RPC protocol, used by codex app-server. It includes optional sync and async clients for multi-turn conversations with the Codex agent.
Tested against: Codex CLI 0.146.0
cargo add codex-codesRequires the Codex CLI (codex binary) to be installed and available in PATH.
| Feature | Description | WASM-compatible |
|---|---|---|
types |
Core message types only (minimal dependencies) | Yes |
sync-client |
Synchronous client with blocking I/O | No |
async-client |
Asynchronous client with tokio runtime | No |
All features are enabled by default.
[dependencies]
codex-codes = { version = "0.128", default-features = false, features = ["types"] }[dependencies]
codex-codes = { version = "0.128", default-features = false, features = ["sync-client"] }[dependencies]
codex-codes = { version = "0.128", default-features = false, features = ["async-client"] }use codex_codes::{AsyncClient, ThreadStartParams, TurnStartParams, UserInput, ServerMessage};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = AsyncClient::start().await?;
// Start a thread
let thread = client.thread_start(&ThreadStartParams::default()).await?;
// Send a turn
client.turn_start(&TurnStartParams {
thread_id: thread.thread_id().to_string(),
input: vec![UserInput::Text { text: "What is 2 + 2?".into() }],
model: None,
reasoning_effort: None,
sandbox_policy: None,
}).await?;
// Stream notifications
while let Some(msg) = client.next_message().await? {
match msg {
ServerMessage::Notification { method, params } => {
println!("{}: {:?}", method, params);
if method == "turn/completed" { break; }
}
ServerMessage::Request { id, method, .. } => {
// Handle approval requests
client.respond(id, &serde_json::json!({"decision": "accept"})).await?;
}
}
}
client.shutdown().await?;
Ok(())
}use codex_codes::{SyncClient, ThreadStartParams, TurnStartParams, UserInput, ServerMessage};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = SyncClient::start()?;
let thread = client.thread_start(&ThreadStartParams::default())?;
client.turn_start(&TurnStartParams {
thread_id: thread.thread_id().to_string(),
input: vec![UserInput::Text { text: "What is 2 + 2?".into() }],
model: None,
reasoning_effort: None,
sandbox_policy: None,
})?;
for result in client.events() {
let msg = result?;
match &msg {
ServerMessage::Notification { method, .. } => {
if method == "turn/completed" { break; }
}
_ => {}
}
}
Ok(())
}Use RawAsyncClient when the caller owns JSON-RPC correlation and only needs
newline framing. Neither method decodes JSON.
use codex_codes::{AppServerBuilder, JsonRpcRequest, RawAsyncClient, RequestId};
# async fn example() -> codex_codes::Result<()> {
let builder = AppServerBuilder::new().working_directory("/workspace");
let mut client = RawAsyncClient::start_with(builder).await?;
let request = JsonRpcRequest {
id: RequestId::Integer(1),
method: "initialize".to_string(),
params: Some(serde_json::json!({})),
};
client.send(&request).await?;
let raw_line = client.next_line().await?;
# Ok(())
# }Typed protocol parsing remains available separately:
use codex_codes::{ThreadItem, JsonRpcMessage, RequestId};
// Parse exec-format JSONL events
let item_json = r#"{"type":"agent_message","id":"msg_1","text":"Hello!"}"#;
let item: ThreadItem = serde_json::from_str(item_json).unwrap();
// Parse app-server JSON-RPC messages
let rpc_json = r#"{"id":1,"result":{"threadId":"th_abc"}}"#;
let msg: JsonRpcMessage = serde_json::from_str(rpc_json).unwrap();The crate supports two protocol modes:
The codex app-server --listen stdio:// process speaks a JSON-RPC 2.0 protocol (without the "jsonrpc":"2.0" field) over newline-delimited stdio.
Lifecycle: initialize -> thread/start -> turn/start -> stream notifications -> turn/completed -> next turn/start
Approval flows: The server sends requests back to the client for command execution and file change approvals.
The codex exec --json - one-shot protocol emits ThreadEvent JSONL lines. These types are still available for parsing captures.
RequestId-- String or integer request identifierJsonRpcRequest,JsonRpcResponse,JsonRpcError,JsonRpcNotificationJsonRpcMessage-- Untagged union of all message types
- Thread lifecycle:
ThreadStartParams/Response,ThreadArchiveParams/Response - Turn lifecycle:
TurnStartParams/Response,TurnInterruptParams/Response - Notifications:
TurnCompletedNotification,AgentMessageDeltaNotification, etc. - Approvals:
CommandExecutionApprovalParams/Response,FileChangeApprovalParams/Response UserInput,Turn,TurnStatus,ServerMessage
Discriminated union of agent action items (shared between exec and app-server):
agent_message/agentMessage-- Text output from the modelreasoning-- Chain-of-thought reasoningcommand_execution/commandExecution-- Shell command with outputfile_change/fileChange-- File modificationsmcp_tool_call/mcpToolCall-- MCP tool invocationweb_search/webSearch-- Web search querytodo_list/todoList-- Task tracking listerror-- Error item
thread.started,turn.started,turn.completed,turn.faileditem.started,item.updated,item.completederror
Tested against: Codex CLI 0.146.0
The crate version tracks the Codex CLI version. If you're using a different CLI version, please report whether it works at: https://github.com/meawoppl/rust-code-agent-sdks/issues
The Codex CLI publishes its own JSON Schema bundle via codex app-server generate-json-schema --out DIR. A snapshot of the output lives at tests/schemas/codex_app_server_protocol.v2.schemas.json.
Run the scorecard to see which JSON-RPC methods this crate models vs. what the upstream schema enumerates, and whether our typed structs' serde shape still matches the wire:
cargo run --example schema_coveragePer method, the report marks:
✓modeled incodex-codesand a hand-rolled sample validates against the schema (drift-checked)◐modeled, but no sample registered yet — grow the registry inexamples/schema_coverage.rsto drift-check it⚠modeled, sample serialized, but did NOT match the schema (drift)✗not modeled at all
Override the schema with CODEX_SCHEMA_PATH=/path/to/fresh/schemas.json to validate against a freshly-generated schema (e.g. in CI).
Apache-2.0. See LICENSE.
Codex login is drivable through the app-server protocol itself — no process scraping:
account_read— current account (plan, email, auth mode)account_login_start— three modes:apiKey(completes inline),chatgpt(returns a browser auth URL),chatgptDeviceCode(returns a user code + verification URL); browser/device completion arrives as theaccount/login/completednotification on the same connectionaccount_login_cancel,account_logout,account_rate_limits_read,account_usage_read
For cheap status probes without an app-server connection,
auth_local::auth_status_local() reads $CODEX_HOME/auth.json and decodes
display-only email/plan labels from the stored id_token — best-effort by
design (the file is codex-internal; the crate owns that risk with fixture
and live tests). logged_in there means stored, not live — use
account_read or codex login status for liveness.