Codex · EN · Part 4
Ch 15 IDE Integration
15-ch-15-IDE-Integration.md
第15章 IDE Integration
The VS Code, Cursor, and Windsurf extensions that integrate with Codex CLI are clients of the app-server protocol. This protocol, exposed via codex app-server, provides a JSON-RPC 2.0 interface that any editor can speak. Understanding this architecture is the first step toward building or customizing IDE integrations.
Extension Architecture
IDE extensions that talk to Codex share a common three-layer architecture. The separation of concerns keeps each layer focused and makes the integration portable across editors.
Rendering diagram...
The app-server client handles all communication with codex app-server. It speaks JSON-RPC 2.0, manages the connection lifecycle, and serializes requests. This layer contains no editor-specific code—it's pure protocol handling. The TypeScript SDK (@openai/codex-sdk) is a production-grade example of this layer.
The UI layer displays results to the user. It renders chat messages, shows command output, presents diff previews, and collects approval decisions. This is editor-agnostic in principle—a chat panel is a chat panel whether in VS Code or Emacs—but the implementation details vary by platform.
The editor integration bridges the UI and the client with the host editor's APIs. It knows how to open files, read selections, show notifications, and register keybindings. This is the most platform-specific layer, but it delegates the heavy lifting to the client and UI layers.
🤿 深入底层:The app-server protocol was inspired by MCP (Model Context Protocol), which we covered in Chapter 12. Both use JSON-RPC 2.0 for structured communication, but app-server is specifically designed for Codex's thread-turn-item model rather than general tool provisioning.
VS Code Extension Deep Dive
The VS Code/Cursor/Windsurf extension demonstrates how these layers fit together. While we can't cover every VS Code API detail, the patterns transfer to other editors.
App-Server Startup
The extension starts the app-server subprocess on activation. Here's a simplified version of the startup sequence:
import { spawn } from "child_process";
import { readline } from "readline";
// [1] Configure the app-server command
const appServerArgs = [
"app-server",
"--listen", "stdio://", // JSONL over stdin/stdout
];
// [2] Spawn the process
const process = spawn("codex", appServerArgs, {
env: { ...process.env, CODEX_INTERNAL_ORIGINATOR: "codex_vscode" }
});
// [3] Set up JSON-RPC communication
const rl = readline.createInterface({
input: process.stdout,
crlfDelay: Infinity
});
The --listen stdio:// flag tells app-server to communicate via newline-delimited JSON (JSONL). Each request is a complete JSON object on one line, and responses come back the same way. This is the most reliable transport for IDE integrations.
Request Handling
The extension wraps raw JSON-RPC calls in a helper method:
let requestId = 0;
async function sendRequest<T>(
method: string,
params: Record<string, unknown>
): Promise<T> {
const id = ++requestId;
const payload = { method, id, params };
// [4] Send request as JSONL
process.stdin.write(JSON.stringify(payload) + "\n");
// [5] Wait for response matching our id
return new Promise((resolve, reject) => {
const handler = (line: string) => {
const response = JSON.parse(line);
if (response.id === id) {
rl.off("line", handler);
if (response.error) {
reject(new Error(response.error.message));
} else {
resolve(response.result as T);
}
}
};
rl.on("line", handler);
});
}
This pattern—send a request with an ID, wait for a response with the matching ID—is the foundation of JSON-RPC communication.
Initialization
Before any other requests, the extension must initialize the connection:
interface InitializeParams {
clientInfo: {
name: string;
title: string;
version: string;
};
capabilities?: {
experimentalApi?: boolean;
optOutNotificationMethods?: string[];
};
}
// [6] Initialize the connection
await sendRequest("initialize", {
clientInfo: {
name: "codex_vscode",
title: "Codex VS Code Extension",
version: "0.1.0"
}
});
// [7] Send initialized notification
process.stdin.write(JSON.stringify({
method: "initialized",
params: {}
}) + "\n");
The clientInfo.name field matters—it's used for OpenAI's compliance logging platform. If you build a new integration for enterprise use, contact OpenAI to get it added to the known clients list.
Notification Processing
The app-server pushes notifications asynchronously. The extension registers a handler for all incoming JSONL lines:
// [8] Handle server notifications
rl.on("line", (line: string) => {
const message = JSON.parse(line);
// Responses are handled by sendRequest's promise
if ("id" in message) return;
// [9] Route notifications by method
switch (message.method) {
case "thread/started":
handleThreadStarted(message.params);
break;
case "item/started":
handleItemStarted(message.params);
break;
case "item/completed":
handleItemCompleted(message.params);
break;
case "turn/completed":
handleTurnCompleted(message.params);
break;
case "item/commandExecution/requestApproval":
handleCommandApproval(message);
break;
case "item/fileChange/requestApproval":
handleFileApproval(message);
break;
// ... more notification types
}
});
Notifications represent events in the Codex runtime—a thread starting, an item beginning, a command needing approval. The extension maps these to UI updates.
UI Updates
The WebView interface renders the chat panel. Here's a simplified view handler:
// [10] Update the WebView with new content
function handleItemCompleted(event: { item: ThreadItem }) {
const { item } = event;
switch (item.type) {
case "agentMessage":
webView.postMessage({
type: "appendMessage",
content: item.text,
role: "assistant"
});
break;
case "commandExecution":
webView.postMessage({
type: "appendCommand",
command: item.command,
output: item.aggregatedOutput,
exitCode: item.exitCode
});
break;
case "fileChange":
webView.postMessage({
type: "appendDiff",
changes: item.changes
});
break;
}
}
The WebView is just a web page loaded in an iframe. It receives messages from the extension host and renders them using HTML/CSS/JS. This keeps the UI code separate from VS Code APIs.
Chat Panel
The full chat panel flow brings these pieces together:
Rendering diagram...
The user sees a smooth streaming experience, but underneath it's a choreographed dance of JSON-RPC messages and notifications.
⚠️ 避坑指南:Always handle the
turn/completednotification even if you've processed all items. A turn can fail after items complete, and the failure state carries important error information. Don't assume success untilturn/completedarrives with astatus: "completed"field.
Customizing Extensions
The VS Code extension is designed for customization. You can modify keybindings, add commands, and change UI behavior without forking the entire codebase.
Keybinding Customization
VS Code stores keybindings in keybindings.json. Here's how to add a custom shortcut for quick Codex access:
[
{
"key": "ctrl+shift+c",
"command": "codex.openChat",
"when": "editorTextFocus"
},
{
"key": "ctrl+enter",
"command": "codex.submitInput",
"when": "codex.chatFocus"
}
]
The first command opens the chat panel when the editor has focus. The second submits your input when the chat panel is active.
Command Addition
You can define custom commands that trigger Codex workflows. Add to your package.json contributes section:
{
"contributes": {
"commands": [
{
"command": "codex.explainSelection",
"title": "Codex: Explain Selection",
"category": "Codex"
}
],
"menus": {
"editor/context": [
{
"command": "codex.explainSelection",
"when": "editorHasSelection"
}
]
}
}
}
Then register the command handler:
import * as vscode from "vscode";
export function activate(context: vscode.ExtensionContext) {
// [11] Register custom command
const disposable = vscode.commands.registerCommand(
"codex.explainSelection",
async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) return;
const selection = editor.document.getText(editor.selection);
const thread = await ensureCodexThread();
await thread.turnStart({
input: [{
type: "text",
text: `Explain this code:\n\n${selection}`
}]
});
}
);
context.subscriptions.push(disposable);
}
This command reads the selected text and sends it to Codex with an "Explain this code" prefix.
UI Behavior Changes
You can modify how the extension displays information. For example, to change the approval prompt behavior:
// [12] Custom approval handler
async function handleCustomApproval(
request: ApprovalRequest
): Promise<ApprovalDecision> {
const config = vscode.workspace.getConfiguration("codex");
const autoApproveReads = config.get<boolean>("autoApproveReads");
if (autoApproveReads && isReadOnlyCommand(request.command)) {
return { decision: "accept" };
}
// Otherwise, show the approval UI
return showApprovalUI(request);
}
Register this in your extension's settings schema:
{
"configuration": {
"title": "Codex",
"properties": {
"codex.autoApproveReads": {
"type": "boolean",
"default": false,
"description": "Auto-approve read-only commands"
}
}
}
}
Safe Customization Patterns
When customizing, follow these rules to stay within safe boundaries:
- Never bypass the app-server — Always communicate via JSON-RPC. Direct access to Codex's internal files or databases breaks encapsulation.
- Preserve request/response IDs — Your wrapper must track IDs correctly or responses will be misrouted.
- Handle all notifications — Don't ignore notification types you don't use. Future protocol versions may add required fields.
- Respect error responses — JSON-RPC errors contain structured information. Surface them to the user rather than silently failing.
☕ 茶水间时间:The Cursor editor was built by former AI researchers who wanted an IDE-first experience. Its Codex integration is essentially a fork of the VS Code extension with restructured UI panels. Windsurf takes this further with a split-pane design that keeps code and chat side-by-side. All three speak the same app-server protocol—they just render it differently.
Building for New Editors
The app-server protocol is editor-agnostic. You can build integrations for any editor that supports subprocesses and text rendering. Here's how to approach two popular alternatives: Neovim and Emacs.
Minimal Implementation Requirements
Every integration needs three capabilities:
- Start the app-server — Launch
codex app-server --listen stdio://as a subprocess - Send requests — Write JSON-RPC messages to stdin and read responses from stdout
- Display responses — Render messages, diffs, and approvals in your editor's UI
Let's see what this looks like in practice.
Neovim Example
Neovim users can build a plugin using Lua. Here's a minimal skeleton:
-- codex.lua - Neovim plugin skeleton
local M = {}
-- [13] App-server state
local state = {
job_id = nil,
request_id = 0,
pending_callbacks = {}
}
-- [14] Start the app-server
function M.start()
state.job_id = vim.fn.jobstart({
"codex", "app-server", "--listen", "stdio://"
}, {
on_stdout = function(_, data)
for _, line in ipairs(data) do
if line ~= "" then
M.handle_output(line)
end
end
end,
on_stderr = function(_, data)
-- Log errors for debugging
vim.notify(table.concat(data, "\n"), vim.log.levels.ERROR)
end
})
-- [15] Initialize connection
M.send_request("initialize", {
clientInfo = {
name = "codex_neovim",
title = "Codex Neovim Plugin",
version = "0.1.0"
}
}, function(result)
-- Send initialized notification
M.send_notification("initialized", {})
end)
end
-- [16] Send a JSON-RPC request
function M.send_request(method, params, callback)
state.request_id = state.request_id + 1
local payload = vim.json.encode({
method = method,
id = state.request_id,
params = params
})
state.pending_callbacks[state.request_id] = callback
-- Write to job stdin
vim.fn.chansend(state.job_id, payload .. "\n")
end
-- [17] Send a JSON-RPC notification (no response expected)
function M.send_notification(method, params)
local payload = vim.json.encode({
method = method,
params = params
})
vim.fn.chansend(state.job_id, payload .. "\n")
end
-- [18] Handle stdout from app-server
function M.handle_output(line)
local ok, message = pcall(vim.json.decode, line)
if not ok then return end
-- [19] Route responses to pending callbacks
if message.id then
local callback = state.pending_callbacks[message.id]
if callback then
state.pending_callbacks[message.id] = nil
callback(message.result or message.error)
end
return
end
-- [20] Route notifications to handlers
local method = message.method
if method == "thread/started" then
M.handle_thread_started(message.params)
elseif method == "item/started" then
M.handle_item_started(message.params)
elseif method == "item/completed" then
M.handle_item_completed(message.params)
elseif method == "item/agentMessage/delta" then
M.handle_delta(message.params)
end
end
-- [21] Notification handlers
function M.handle_thread_started(params)
vim.notify("Thread started: " .. params.thread.id, vim.log.levels.INFO)
end
function M.handle_item_started(params)
local item = params.item
if item.type == "commandExecution" then
vim.notify("Running: " .. item.command, vim.log.levels.INFO)
end
end
function M.handle_item_completed(params)
local item = params.item
if item.type == "agentMessage" then
-- Display in a floating window
M.show_chat(item.text)
elseif item.type == "fileChange" then
M.show_diff(item.changes)
end
end
function M.handle_delta(params)
-- Append streaming text to current buffer
if M.chat_buf then
local lines = vim.split(params.delta, "\n")
vim.api.nvim_buf_set_lines(M.chat_buf, -1, -1, false, lines)
end
end
-- [22] UI helper: Show floating chat window
function M.show_chat(text)
local buf = vim.api.nvim_create_buf(false, true)
M.chat_buf = buf
local lines = vim.split(text, "\n")
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
local width = 80
local height = 20
local row = math.floor((vim.o.lines - height) / 2)
local col = math.floor((vim.o.columns - width) / 2)
vim.api.nvim_open_win(buf, true, {
relative = "editor",
width = width,
height = height,
row = row,
col = col,
style = "minimal",
border = "rounded"
})
end
-- [23] UI helper: Show file diff
function M.show_diff(changes)
for _, change in ipairs(changes) do
vim.cmd("tabnew " .. change.path)
-- Apply the diff using Neovim's LSP diff API
vim.lsp.util.apply_workspace_edit({
documentChanges = {{
textDocument = { uri = "file://" .. change.path },
edits = {} -- Parse unified diff into LSP edits
}}
}, {})
end
end
-- [24] Start a new turn
function M.send_input(text)
M.send_request("turn/start", {
threadId = M.current_thread,
input = { { type = "text", text = text } }
}, function(result)
M.current_turn = result.turn.id
end)
end
return M
To use this plugin, users would add to their init.lua:
-- In init.lua
local codex = require("codex")
codex.start()
vim.api.nvim_create_user_command("CodexChat", function(opts)
codex.send_input(opts.args)
end, { nargs = 1 })
Emacs Example
Emacs integration uses Emacs Lisp with similar patterns:
;; codex.el - Emacs integration skeleton
(require 'json)
(defgroup codex nil
"Codex AI assistant integration."
:prefix "codex-"
:group 'tools)
(defcustom codex-executable "codex"
"Path to the codex executable."
:type 'string
:group 'codex)
;; [25] Process and state variables
(defvar codex--process nil
"The app-server process.")
(defvar codex--request-id 0
"Current request ID counter.")
(defvar codex--pending-callbacks (make-hash-table)
"Callbacks for pending requests.")
(defvar codex--current-thread nil
"Active thread ID.")
;; [26] Start the app-server
(defun codex-start ()
"Start the Codex app-server process."
(interactive)
(setq codex--process
(make-process
:name "codex-app-server"
:command (list codex-executable "app-server" "--listen" "stdio://")
:filter 'codex--process-filter
:sentinel 'codex--process-sentinel
:noquery t))
;; Initialize connection
(codex--send-request
"initialize"
`((clientInfo . ((name . "codex_emacs")
(title . "Codex Emacs Integration")
(version . "0.1.0")))))
(codex--send-notification "initialized" nil))
;; [27] Process filter for stdout
(defun codex--process-filter (proc string)
"Handle output from app-server."
(let ((lines (split-string string "\n" t)))
(dolist (line lines)
(when (string-prefix-p "{" line)
(let* ((message (json-read-from-string line))
(id (cdr (assq 'id message)))
(method (cdr (assq 'method message)))
(params (cdr (assq 'params message))))
(cond
;; Response to a request
(id
(let ((callback (gethash id codex--pending-callbacks)))
(when callback
(remhash id codex--pending-callbacks)
(funcall callback message))))
;; Notification
(method
(codex--handle-notification method params))))))))
;; [28] Send a JSON-RPC request
(defun codex--send-request (method params callback)
"Send a JSON-RPC request and call CALLBACK with the response."
(setq codex--request-id (1+ codex--request-id))
(puthash codex--request-id callback codex--pending-callbacks)
(let ((payload (json-encode
`((method . ,method)
(id . ,codex--request-id)
(params . ,params)))))
(process-send-string codex--process (concat payload "\n"))))
;; [29] Send a JSON-RPC notification
(defun codex--send-notification (method params)
"Send a JSON-RPC notification (no response expected)."
(let ((payload (json-encode
`((method . ,method)
(params . ,params)))))
(process-send-string codex--process (concat payload "\n"))))
;; [30] Handle notifications
(defun codex--handle-notification (method params)
"Route notifications to appropriate handlers."
(pcase method
("thread/started"
(setq codex--current-thread (cdr (assq 'id (assq 'thread params)))))
("item/started"
(codex--handle-item-started params))
("item/completed"
(codex--handle-item-completed params))
("item/agentMessage/delta"
(codex--append-to-chat-buffer (cdr (assq 'delta params))))))
;; [31] UI: Display in chat buffer
(defun codex--append-to-chat-buffer (text)
"Append text to the Codex chat buffer."
(with-current-buffer (codex--get-chat-buffer)
(goto-char (point-max))
(insert text)
(goto-char (point-max))))
(defun codex--get-chat-buffer ()
"Get or create the chat buffer."
(or (get-buffer "*Codex Chat*")
(let ((buf (get-buffer-create "*Codex Chat*")))
(with-current-buffer buf
(text-mode))
buf)))
;; [32] User command: Send input
(defun codex-send-input (text)
"Send TEXT to the current thread."
(interactive "sCodex: ")
(codex--send-request
"turn/start"
`((threadId . ,codex--current-thread)
(input . [((type . "text") (text . ,text))]))
(lambda (response)
(message "Turn started"))))
;; [33] User command: Open chat
(defun codex-open-chat ()
"Open the Codex chat buffer."
(interactive)
(switch-to-buffer (codex--get-chat-buffer)))
(provide 'codex)
Emacs users would add to their config:
(require 'codex)
(codex-start)
;; Keybindings
(global-set-key (kbd "C-c c") 'codex-open-chat)
(global-set-key (kbd "C-c x") 'codex-send-input)
Minimal Implementation Checklist
Whether targeting Neovim, Emacs, or another editor, ensure your integration has:
- Process management — Start/stop app-server subprocess
- Request/response matching — Track IDs for async replies
- Notification routing — Handle thread/item/turn events
- Text display — Render agent messages
- Approval UI — Collect user decisions for commands and edits
- Error handling — Surface JSON-RPC errors to users
- Configuration — Allow users to set model, sandbox, approval policy
The code examples above demonstrate all of these pieces. Adapt them to your editor's specific APIs and UI conventions.
Integration and customization are powerful, but performance matters – the final chapter covers optimization.