这个问题很容易被一句“SDK 封装了 agent”带过去。源码里更准确的说法是: SDK 和 app-server 提供的是一组入口,先把外部调用拆成三类 runtime 已经认识的东西: 一段会话、一次执行、以及执行过程里的事件回流。落到源码名上,就是 threadturn、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:ThreadTurnItem

外部调用方想做的事 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/starteditem/*turn/completed notification。 listener 把 core EventMsg 翻成 ServerNotification 进度是流式事实,不能等 response 结束后一次性返回。
恢复、fork、回滚。 thread/resumethread/forkthread/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。里面先拒绝 permissionssandbox 混用,解析 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/startTurnStartParams 也可以按职责读:输入本身是 threadIdclientUserMessageIdinput;额外上下文是 responsesapiClientMetadataadditionalContext; 临时运行覆盖包括 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 同时传了 permissionssandboxPolicy,这里会直接拒绝。 如果覆盖项存在,代码会先调用 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。

Codex client projection,展示 EventMsg 分流到 app-server ServerNotification、TUI streaming、rollout resume 等视图

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, 带上 clientInfocapabilities.experimentalApi,随后发送 initialized

低层 client 暴露的就是 app-server 方法:thread_startthread_resumethread_forkturn_startturn_interruptturn_steer 等。高层 Codex 再把这些方法整理成更自然的对象:构造 Codex() 时启动和 initialize; thread_start() 返回 ThreadThread.turn() 构造 TurnStartParams,调用低层 turn_start,返回 TurnHandleThread.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.completedturn.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 与恢复。这样功能越多,外部集成越不需要重新发明边界。

参考源码