这个问题很容易被一句“SDK 封装了 agent”带过去。源码里更准确的说法是:
SDK 和 app-server 提供的是一组入口,先把外部调用拆成三类 runtime 已经认识的东西:
一段会话、一次执行、以及执行过程里的事件回流。落到源码名上,就是
thread、turn、settings、event 和 notification。
它们不重新实现上下文账本,不重新做权限裁决,
也不绕开 rollout。真正的价值,是把外部应用带到同一套受控运行主线里。
第十一篇的判断很简单:公开接口负责把外部调用纳入 runtime 适配层;
agent 仍由 core runtime 执行。
app-server 把 JSON-RPC 请求变成 typed ClientRequest,
再交给 thread / turn / catalog / filesystem 等 processor;
SDK 再把这条协议包装成更顺手的 Thread.run()、
runStreamed() 或 CLI JSONL 事件。
阅读契约。 本篇只回答外部调用进入 Codex 的边界:transport、初始化、typed request、thread/turn 入口、 notification 回流和 SDK 包装。读完以后,应该能分清三件事: app-server 对外承诺什么接口,core runtime 负责执行什么,以及 SDK 怎样降低调用成本。
证据边界。 app-server 的通信方式、生命周期、API 列表来自公开 README; request dispatch、thread/turn processor、listener、SDK router 来自公开源码。 对 hosted service、远端控制面和私有后端的行为,本文不做实现级推断。
一、先把“嵌入 Codex”拆开
如果你写一个 IDE 插件或自动化脚本,最自然的需求可能是“发一句话,让 Codex 继续工作”。
但源码中的公开边界并没有收缩成一条简单的 prompt -> answer API。
codex app-server 的 README 开头就说明,它是用来支撑富客户端的接口层;
对外通信采用 JSON-RPC 2.0 消息,默认 transport 是 stdio JSONL,还列出 websocket、unix socket 和 off
几种模式。随后 README 把公开模型收敛成三个 primitive:Thread、Turn、Item。
| 外部调用方想做的事 | app-server 看到的形状 | 实际执行归属 | 为什么不能只做 prompt API |
|---|---|---|---|
| 开一段新对话。 | thread/start,携带 cwd、model、permissions、environments 等配置。 |
ThreadManager 创建 CodexThread。 |
需要建立会话身份、权限基线、rollout 和 listener。 |
| 发起一次用户输入。 | turn/start,携带 input、settings override、additional context。 |
CodexThread.submit(...) 处理 Op::UserInput。 |
输入会影响模型视图、工具权限、usage、事件和持久记录。 |
| 显示执行进度。 | 读取 turn/started、item/*、turn/completed notification。 |
listener 把 core EventMsg 翻成 ServerNotification。 |
进度是流式事实,不能等 response 结束后一次性返回。 |
| 恢复、fork、回滚。 | thread/resume、thread/fork、thread/rollback。 |
第十篇讲过的 rollout / recovery 账本。 | 继续运行依赖可重放证据,而不只是旧 UI 消息。 |
所以这一层真正保护的是边界一致性。IDE、SDK、CLI、同进程调用方可以有不同的调用形态; 进入 runtime 后,它们都要落到同一组 typed request、同一条 thread/turn 主线、同一个 event stream。
如果把这一轮拆成最小可重放形状,外部调用方看到的并不是一条长回复,而是一组 request 和 notification 交替出现。下面只保留关键字段,帮助你把 README 里的 primitive 和后面的源码 owner 对上:
{"id":1,"method":"initialize","params":{"clientInfo":{"name":"ide"}}}
{"method":"initialized","params":{}}
{"id":2,"method":"thread/start","params":{"cwd":"workspace","model":"gpt-5"}}
{"id":3,"method":"turn/start","params":{"threadId":"t1","input":[{"type":"text","text":"fix failing test"}]}}
{"method":"turn/started","params":{"threadId":"t1","turnId":"u1"}}
{"method":"item/completed","params":{"turnId":"u1","item":{"type":"tool_result"}}}
{"method":"turn/completed","params":{"threadId":"t1","turnId":"u1"}}
这段 JSONL 的价值不在字段完整,而在顺序:连接先初始化,thread 再建立身份和边界, turn 承接一次用户输入,执行过程再用 notification 回流。SDK 的便利方法只是把这组消息包起来; 真正进入 Codex 时,仍然要遵守这条生命周期。
二、app-server 先把连接变成一个受控会话
app-server 的第一道门先落在 initialize。
README 在 lifecycle 里写得很清楚:每个 transport connection 打开后要先发送一次
initialize request,再发 initialized notification;在这之前的其他请求会被拒绝。
这个门让后续 request 可以带上 client metadata、experimental API 能力和 notification opt-out。
源码里的入口也对应这个顺序。process_request 先把普通 JSON-RPC request
通过 serde 还原成 typed ClientRequest;同进程调用方可以绕过 JSON 反序列化,
直接走 process_client_request,但注释明确说它仍然 delegate 到
handle_client_request,保持相同语义。随后 handle_client_request
单独处理 Initialize,连接成功初始化后才把其他请求交给
dispatch_initialized_client_request。
public edge:
JSON-RPC request
-> serde ClientRequest
-> initialize gate
-> experimental gate
-> serialization scope
-> request processor
这里还有一个容易忽略的设计点:初始化之后,request 也会按作用范围排队。
protocol macro 给 ClientRequest 生成 serialization_scope();
app-server 再根据 scope 选择全局、thread、thread path、command process、filesystem watch 等队列。
能共享读的请求走 SharedRead,会改状态的请求走 Exclusive。
这直接保护公共接口的顺序纪律,避免同一个 thread 的 mutating 操作被打乱。
下一节要看的 thread/start 就属于会改变 thread 状态的入口:
它创建 runtime 容器、订阅事件、写入 watch state,不能和同一个 thread 或同一条 path 上的状态变更乱序交错。
三、thread/start 创建 runtime 容器
ThreadStartParams 看起来字段很多,但可以按职责分成四组:
identity 和 workspace 相关的 cwd、workspace roots、service name;
authority 相关的 approval policy、sandbox、permissions;
model view 相关的 model、provider、service tier、base instructions、developer instructions、personality;
扩展能力相关的 environments、dynamic tools、selected capability roots。把这些字段放在一起,就能看出
thread/start 的真实职责:为后续 turns 建立一套运行基线。
message_processor 收到 ClientRequest::ThreadStart 后进入
thread_processor.thread_start。里面先拒绝 permissions 和
sandbox 混用,解析 environments,构造 config overrides,并把后续工作放进后台
thread_start_task。真正创建 thread 时,代码调用
ThreadManager.start_thread_with_options(StartThreadOptions { ... }),
把 InitialHistory::New / InitialHistory::Cleared、dynamic tools、
metrics service name、parent trace、environment selection 和 extension init 一起交给 core。
thread/start 的 response 只是第一半。
app-server 还会自动 attach conversation listener、把 thread 写入 watch manager,
先发送 ThreadStartResponse,再广播 thread/started notification。
外部调用方从一开始就被接到事件流上。
四、turn/start 才把用户输入变成 core operation
thread 建好以后,外部调用方真正驱动 agent 的入口是 turn/start。
TurnStartParams 也可以按职责读:输入本身是 threadId、
clientUserMessageId 和 input;额外上下文是
responsesapiClientMetadata 与 additionalContext;
临时运行覆盖包括 environments、cwd、workspace roots、approval、sandbox、permissions、
model、effort、summary、personality 和 collaboration mode;输出约束则是
outputSchema。这些字段告诉我们:
一次 turn 已经超出纯文本追加:它可以改变后续运行设置,也可以带额外上下文和结构化输出约束。
turn_start_inner 的顺序很适合作为阅读主线:
先加载 thread,确认 direct input 允许,校验输入大小;再把 v2 input 映射成 core input item,
把 additionalContext 映射为 core 结构;接着构造 settings overrides。
如果 caller 同时传了 permissions 和 sandboxPolicy,这里会直接拒绝。
如果覆盖项存在,代码会先调用 preview_thread_settings_overrides 验证这些设置能否应用。
turn/start shape:
input + overrides + additionalContext
-> CoreInputItem[]
-> ThreadSettingsOverrides
-> Op::UserInput
-> CodexThread.submit_user_input_with_client_user_message_id()
最后一步才是关键:app-server 构造 Op::UserInput,交给
CodexThread。返回给 caller 的 TurnStartResponse 里,
turn 状态是 InProgress,items 还没有加载。后续事实要靠 notification 流回来。
五、进度为什么要走 notification
从使用者角度看,最希望的是一个同步函数直接返回最终答案。但 Codex 的运行过程里有模型 delta、 工具调用、权限请求、计划更新、token usage、rollback marker、thread status 等多种事件。 如果把它们塞进一个最终 response,客户端就无法实时显示,也很难在中途处理 approval 或 interrupt。
app-server 的 listener 就是这条回流路径。
ensure_conversation_listener 先从 ThreadManager 拿到 thread,
再把连接订阅到 thread state。listener task 循环读取 conversation.next_event(),
更新 thread-local 状态,收集当前订阅连接,然后调用
apply_bespoke_event_handling。在这里,
EventMsg::TurnStarted 会变成 ServerNotification::TurnStarted;
delta 类 item 事件会走 item_event_to_server_notification;
turn 完成时会发 ServerNotification::TurnCompleted。
这也解释了为什么 SDK 里一定要有 router。stdio 是一条有序 stdout 流,
同时混着 JSON-RPC response 和 notification。如果多个调用方各自读 stdout,
很快就会互相抢消息。Python SDK 的 MessageRouter 明确把这个问题收口:
response 按 request id 投递;turn notification 按 turn id 投递;早到的 turn 事件会暂存,
等 caller 开始 streaming 后再 replay;transport 失败时,所有 waiter 都会被唤醒,避免调用永远阻塞。
六、Python SDK:把 app-server stdio 包成 thread API
下面两个 SDK 代表两条外部路径:Python 贴近 app-server v2,
TypeScript 当前贴近 codex exec JSONL。先看 Python。
CodexClient 的 docstring
直接说它是基于 stdio 的 typed JSON-RPC client。启动时,它会解析 Codex executable,
运行 codex app-server --listen stdio://,打开 stdin/stdout/stderr,
再启动 stderr drain 和 reader thread。初始化时,它发送 initialize,
带上 clientInfo 和 capabilities.experimentalApi,随后发送
initialized。
低层 client 暴露的就是 app-server 方法:thread_start、
thread_resume、thread_fork、turn_start、
turn_interrupt、turn_steer 等。高层 Codex
再把这些方法整理成更自然的对象:构造 Codex() 时启动和 initialize;
thread_start() 返回 Thread;Thread.turn()
构造 TurnStartParams,调用低层 turn_start,返回
TurnHandle;Thread.run() 则消费 stream,收集
item/completed、usage 和 turn/completed,最后给出
TurnResult。
| SDK 层 | 暴露给调用者 | 底层仍然是什么 | 保护的边界 |
|---|---|---|---|
CodexClient |
typed request、notification、wait / stream helpers。 | stdio JSON-RPC + app-server methods。 | 单一 stdout 不被多个调用方抢读。 |
Codex |
登录、account、thread_start 等面向应用的方法。 | 构造时启动 runtime connection 并 initialize。 | 调用者不需要手写握手和 client metadata。 |
Thread |
run()、turn()、read()。 |
turn/start + notification stream。 |
同步 run 只是收集事件,不改变 runtime 合约。 |
AsyncCodexClient |
async thread / turn / stream helper。 | 包装同步 client 到 worker thread。 | 不让阻塞式读写占住 event loop。 |
七、TypeScript SDK:更轻的一次性 JSONL 包装
TypeScript SDK 的 README 写得更直接:它包住 @openai/codex 里的
codex CLI,spawn CLI,并通过 stdin/stdout 交换 JSONL events。
这和 Python SDK 不同。Python 贴着 app-server v2 protocol;TypeScript 当前主要走
codex exec --experimental-json。
CodexExec.run 组装的命令也能看出这点:
先放入 exec --experimental-json,再根据调用参数追加 config override、model、
sandbox、working directory、additional directories、output schema、reasoning effort、
network access、web search、approval policy;如果 thread id 存在,再追加
resume <threadId>。随后它 spawn 子进程,把 input 写入 stdin,
逐行读取 stdout,并把每一行 JSONL event yield 出去。
Thread.runStreamedInternal 负责把调用者输入归一化成 prompt 和 images,
传给 CodexExec.run,再逐行 JSON.parse。收到
thread.started 时,它把 thread id 存回 Thread 对象;
run() 则把 item.completed 和 turn.completed
收集成最终结果。它牺牲了一部分 app-server v2 的丰富方法面,换来更简单的 CLI 嵌入路径。
八、同进程 host:少了进程边界,不能少协议边界
源码里还有一个很能说明设计意图的文件:in_process.rs。
文件头部注释说,它用 bounded in-memory channels 替换 socket/stdio transport,
但仍然运行已有 MessageProcessor 和 outbound routing。注释还强调:
incoming request 是 typed ClientRequest,response 仍然走和 stdio/websocket
一样的 JSON-RPC result envelope。目的在于保留 app-server 语义,
避免在同进程路径里另造执行合约。
start() 甚至会在返回 handle 前内部完成
initialize / initialized。后续 request 被送进
process_client_request,初始化状态、experimental capability、opt-out notification
也同步到 outbound state。也就是说,即使调用方已经和 runtime 在同一个进程里,
仍然要尊重同样的 session gate 和请求语义。
九、把这一层带走
到这里,Codex 的外部接入路径可以闭合了。第一篇从一次请求进入 runtime; 中间几篇看上下文、工具、权限、投影、扩展、hooks、cache、rollout; 本篇回到外部入口:SDK 和 app-server 把 IDE、脚本、CLI、同进程 host 带进同一套受控 runtime。 下一篇再看 thread 之外的长期状态:Codex 怎样把旧 rollout 里的稳定经验沉淀成 memory。
| 如果你要接入 Codex | 先问的问题 | 对应机制 | 不要误读成 |
|---|---|---|---|
| 我要写 IDE 或 rich client。 | 是否需要完整 thread/turn/event/control 面? | codex app-server + JSON-RPC notification stream。 |
一个同步 answer API。 |
| 我要在 Python 程序里自动化。 | 是否需要 app-server v2 方法和 typed notification? | Python SDK 基于 stdio app-server。 | 直接读写聊天文本。 |
| 我要在 Node/TS 里跑一次任务。 | 是否接受 CLI JSONL 事件作为主接口? | TypeScript SDK 基于 codex exec --experimental-json。 |
完整 app-server client。 |
| 我要同进程嵌入。 | 能否保留 app-server 语义? | in_process typed request + 同样的返回包络。 |
绕开初始化、队列和 notification。 |
这也是这个系列想保留的最后一个工程判断:一个 agent runtime 的公共接口,最好不要暴露成“让模型说话”的快捷口。 它应该把外部调用纳入 runtime 已经建立的边界:第二篇讲的上下文账本、第五篇讲的权限门、 第六篇讲的事件投影、第十篇讲的 rollout 与恢复。这样功能越多,外部集成越不需要重新发明边界。
参考源码
- app-server README:富客户端、transport、primitives 与 initialize lifecycle
- app-server API overview:thread、turn、rollback、steer、interrupt
core-apipublic facade re-export thread/runtime primitivesClientRequestmacro 与 serialization scopeServerNotificationmacro 与 JSON-RPC notification projection- JSON-RPC request 与 typed request 进入同一处理路径
- initialize gate、experimental gate 与 serialization queue dispatch
ThreadStart/ThreadResume/ThreadForkdispatchTurnStart/TurnSteer/TurnInterruptdispatch- request serialization queue keys 与 exclusive/shared read
ThreadStartParamspublic shapeThreadResumeParamsresume variantsThreadForkParamsfork variantsTurnStartParamspublic shapethread_start_inner与thread_start_taskturn_start_inner构造Op::UserInput- turn settings overrides validation
- conversation listener subscription
- listener task 读取 core events 并投影
TurnStarted/TurnCompletenotification handling- item delta events 转成
ServerNotification TurnCompletednotification emission- in-process app-server runtime host 设计说明
- in-process start 内部完成 initialize / initialized
- in-process request 仍进入
process_client_request - Python SDK
CodexClient配置、启动 app-server 与 initialize - Python SDK thread start/resume/fork wrappers
- Python SDK turn start、wait 和 stream helpers
- Python SDK
MessageRouter分流 response 与 notifications - Python SDK high-level
Codex和thread_start - Python SDK
Thread.run/Thread.turn - Python SDK 收集
TurnResult - TypeScript SDK README:CLI JSONL wrapper、streaming、resume
- TypeScript SDK
CodexExec.run构造codex exec --experimental-json - TypeScript SDK spawn CLI 并逐行读取 stdout
- TypeScript SDK
Thread.runStreamed/run