这个问题很容易被一句“SDK 封装了 agent”带过去。源码里更准确的说法是: SDK 和 app-server 提供的是一组入口,先把外部调用拆成三类 runtime 已经认识的东西: 一段会话、一次执行、以及执行过程里的事件回流。落到源码名上,就是 thread、turn、settings、event 和 notification。 它们不重新实现对话历史,不重新做权限判断, 也不绕开 rollout。它们的价值,是把外部应用转换成 core 已经支持的 thread、turn 和 event。

第十一篇的判断很简单:公开接口负责转换外部调用;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 怎样降低调用成本。

取材范围。 源码快照:5c5308fc9a9e。通信方式和连接生命周期参考 官方 App Server 文档;具体字段、dispatch、thread/turn processor、listener 和 SDK router 以固定公开源码快照为准。文档中仍可能出现较旧接口,例如当前请求枚举已移除 thread/rollback,而旧持久记录仍保留回放支持。本文不推断 hosted service 或私有后端。

一、先把“嵌入 Codex”拆开

如果你写一个 IDE 插件或自动化脚本,“发送一句话”实际需要一段会话、一次执行和一组事件。官方文档用 Thread、Turn、Item 描述它们。app-server 使用 JSON-RPC 消息,stdio 用 JSONL 传输,源码也提供 WebSocket、Unix socket 和同进程入口。turn/start 的响应确认输入提交,最终结果由完成通知给出。

外部调用方想做的事 app-server 看到的形状 实际执行归属 为什么不能只做 prompt API
开一段新对话。 thread/start,携带 cwd、model、permissions、environments 等配置。 ThreadManager 创建 CodexThread。 需要建立会话身份、权限基线、rollout 和 listener。
发起一次用户输入。 turn/start,携带 input、settings override、additional context。 CodexThread.start_or_steer_turn(...) 让 core 决定新建或补充。 输入会影响模型视图、工具权限、usage、事件和持久记录。
显示执行进度。 读取 turn/started、item/*、turn/completed notification。 listener 把 core EventMsg 翻成 ServerNotification。 进度是流式事实,不能等 response 结束后一次性返回。
恢复或 fork。 thread/resume、thread/fork。 第十篇讲过的 rollout / recovery 记录。 继续运行依赖可重放证据,而不只是旧 UI 消息。

这保证了不同接入方式使用相同语义。IDE、SDK、CLI、同进程调用方可以有不同的调用形态; 进入 runtime 后,它们都要落到同一组 typed request、同一条 thread/turn 主线、同一个 event stream。

如果把这一轮拆成最小可重放形状,外部调用方看到的并不是一条长回复,而是一组 request 和 notification 交替出现。下面只保留关键字段,帮助你把 文档里的 primitive 和后面的源码模块对上:

下例是新建 turn 的简化 JSONL 形状:省略常规 response、时间戳与可选字段;t1 代表 thread/start 实际返回的 id,通知与响应可交错到达。

{"id":1,"method":"initialize","params":{"clientInfo":{"name":"example_client","version":"1.0.0"}}}
{"method":"initialized"}
{"id":2,"method":"thread/start","params":{}}
{"id":3,"method":"turn/start","params":{"threadId":"t1","input":[{"type":"text","text":"fix failing test","text_elements":[]}]}}
{"method":"turn/started","params":{"threadId":"t1","turn":{"id":"u1","status":"inProgress","items":[],"itemsView":"notLoaded","error":null}}}
{"method":"item/completed","params":{"threadId":"t1","turnId":"u1","item":{"type":"agentMessage","id":"m1","text":"Fixed the test."}}}
{"method":"turn/completed","params":{"threadId":"t1","turn":{"id":"u1","status":"completed","items":[],"itemsView":"notLoaded","error":null}}}

这段 JSONL 的价值不在字段完整,而在顺序:连接先初始化,thread 再建立身份和运行设置, turn 承接一次用户输入,执行过程再用 notification 回流。SDK 的便利方法只是把这组消息包起来; 真正进入 Codex 时,仍然要遵守这条生命周期。

二、app-server 先把连接变成一个受控会话

app-server 的第一项检查落在 initialize。 官方文档在 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 check
  -> experimental API check
  -> 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 决定启动或补充

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 和 provider 配置,再校验输入大小。普通 input 转成 TurnInput::UserInput;另一条 toolOutput 路径接收具名独立工具输出,禁止与非空 input 同传。settings builder 仍拒绝互斥的 permissions / sandboxPolicy。最后构造 TurnInputRequest 并调用 start_or_steer_turn,等待 core 的路由决定。

turn/start shape:
input + overrides + additionalContext
  -> TurnInput + ThreadSettingsOverrides
  -> TurnInputRequest
  -> CodexThread.start_or_steer_turn()
  -> Started | Steered | NotSubmitted

返回值区分 Started、Steered 和 NotSubmitted。前两种生成状态为 InProgress、itemsView = notLoaded 的响应;Steered 使用已有 turn id。拒绝则返回错误,例如 server 正在排空。成功只表示输入已接受,最终完成、失败或中断仍须等待通知。

五、进度为什么要走 notification

从使用者角度看,最希望的是一个同步函数直接返回最终答案。但 Codex 的运行过程里有模型 delta、 工具调用、权限请求、计划更新、token usage、thread status 等多种事件。 如果把它们塞进一个最终 response,客户端就无法实时显示,也很难在中途处理 approval 或 interrupt。

core 经 app-server 通知 TUI 与其他客户端,rollout 为后续 turn 和 item 重建提供记录
客户端实时通知与 rollout 后续重建是两条不同路径。

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。

stdio 的单一 stdout 同时包含 JSON-RPC response 和 notification,必须由一个 reader 分发。Python MessageRouter 按 request id 路由响应,并为每个 turn consumer 保存独立游标:多个 handle 指向同一 turn 时,不会互相抢走事件。请求发送前记录游标并缓冲早到事件;所有订阅者都读过的前缀才会清理。新的独立订阅从下一条事件开始,不等于重放整个 turn;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 仍然复用 app-server 语义

源码里还有一个很能说明设计意图的文件: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 初始化检查并遵守请求语义。

九、把这一层带走

到这里,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 与恢复。这样功能越多,外部集成越不需要重新实现这些机制。

参考源码