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/completed notification 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 until turn/completed arrives with a status: "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:

  1. Never bypass the app-server — Always communicate via JSON-RPC. Direct access to Codex's internal files or databases breaks encapsulation.
  2. Preserve request/response IDs — Your wrapper must track IDs correctly or responses will be misrouted.
  3. Handle all notifications — Don't ignore notification types you don't use. Future protocol versions may add required fields.
  4. 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:

  1. Start the app-server — Launch codex app-server --listen stdio:// as a subprocess
  2. Send requests — Write JSON-RPC messages to stdin and read responses from stdout
  3. 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.