Skip to content

The Codex SDK (@openai/codex-sdk) is OpenAI’s TypeScript library for running the Codex agent from application code. It spawns the codex CLI, starts or resumes threads, and returns each turn’s final response, file changes, commands and token usage. It suits tools that need program logic between turns; a one-shot script needs only codex exec.

Your team wants a “Fix this” button on failing CI runs: the service checks out the branch, asks Codex for a root cause and a minimal patch, and opens a pull request only when the tests pass. A shell loop around codex exec gets you halfway, then you need typed results, a retry on the same conversation and a gate that refuses a patch touching files it should not. This page is for the developer building that service and the tech lead who has to trust what it ships.

  • A working TypeScript integration with explicit sandbox, approval and network settings, instead of whatever the host’s config.toml happens to say.
  • A structured-output schema that turns the agent’s answer into data your code can check.
  • A verification gate that re-runs the tests itself and rejects out-of-scope edits, so nobody has to read every diff.
  • Patterns for streaming progress, resuming a thread by ID and cancelling a runaway turn.
  • A failure-modes table with the exact error strings the SDK throws and how to recover from each.

When should you use the SDK instead of codex exec?

Section titled “When should you use the SDK instead of codex exec?”

The SDK is a wrapper, not a separate agent. The package README says it “spawns the CLI and exchanges JSONL events over stdin/stdout”: every run() call starts codex exec --experimental-json with flags built from your options. That flag is hidden: codex exec --help lists only --json (“Print events to stdout as JSONL”), but --experimental-json is an alias of --json: codex-rs/exec/src/cli.rs at rust-v0.162.1 declares it, codex exec --experimental-json --help runs on the 0.162.1 binary, and dist/index.js in the @openai/codex-sdk 0.162.1 npm package still passes it (checked 2026-10-13). In your own scripts, use the documented --json. Anything the CLI cannot do, the SDK cannot do either. What it adds is typed events, thread objects and error handling in your language.

You needUseWhy
One prompt from a shell script or CI stepcodex exec--json, --output-schema and codex exec resume already cover it, with no Node service to maintain
Codex on every pull request in GitHub Actionsopenai/codex-action@v1Packaged permissions, no code
Recurring work on a scheduleCodex automationsScheduling, history and review UI are built in
Long tasks off your machine, in OpenAI’s containersCodex cloudThe environment and the container are managed for you
Program logic between turns: branch on results, retry, gate, feed a queue or a dashboardCodex SDKTyped items, per-thread options, structured output, AbortSignal
A rich client with interactive approvalscodex app-server (experimental in 0.157.1)JSON-RPC protocol; the SDK has no approval callback

Codex can no longer run as an MCP server (the mcp-server subcommand was removed in 0.154.0), so the SDK, codex exec and codex app-server are the ways to control it from code. If you are choosing between vendors, driving agents from code compares the Codex SDK with the Claude Agent SDK and the Cursor SDK on the same job.

Everything below is checked against @openai/codex-sdk 0.157.1 (its README and type definitions) and codex exec --help from Codex CLI 0.157.1, on 2026-09-26.

  1. Install the package in a Node.js 18+ project. It depends on @openai/codex at the same version (both 0.162.1 on 2026-10-12), so pin it exactly and upgrade both together:

    Terminal window
    npm install --save-exact @openai/codex-sdk

    For Python, the official package is openai-codex on PyPI (0.162.1 on 2026-10-12, version-locked to the CLI). The similarly named openai-codex-sdk is not published by OpenAI; do not install it.

  2. Authenticate the machine that runs the SDK. On a workstation, codex login stores credentials that the spawned CLI reads. On a server, read an API key from your secret store and pass it as apiKey; the SDK injects it into the CLI process as CODEX_API_KEY. See Codex authentication for the account options.

  3. Give every job its own Git checkout. The CLI refuses to run outside a Git repository unless you pass skipGitRepoCheck: true, and two threads editing the same directory overwrite each other. A Git worktree per job solves both.

  4. Set the thread’s safety options explicitly, as in the next section. Omitted options fall back to the host’s ~/.codex/config.toml, which differs between a laptop and a CI runner. networkAccessEnabled: false covers only the agent’s shell commands: also set webSearchMode: "disabled", and give the job a CODEX_HOME whose config.toml declares no MCP servers, because web search and MCP tools are separate network paths.

Run a first thread with explicit safety settings

Section titled “Run a first thread with explicit safety settings”
import { execFileSync } from "node:child_process";
import { Codex } from "@openai/codex-sdk";
// Record the base commit before the first turn: the verification gate diffs against it.
const base = execFileSync("git", ["rev-parse", "HEAD"], { cwd: "/srv/jobs/4812/repo" }).toString().trim();
const codex = new Codex({ apiKey: process.env.CODEX_API_KEY });
const thread = codex.startThread({
workingDirectory: "/srv/jobs/4812/repo", // this job's own worktree
sandboxMode: "workspace-write", // edits inside the checkout only
approvalPolicy: "never", // nobody is there to approve; blocked actions fail
networkAccessEnabled: false, // no network for the agent's shell commands
webSearchMode: "disabled", // web search is a separate switch
});
const turn = await thread.run(
"Run `npm test`. For each failing test, find the root cause and describe it. Do not edit files yet."
);
console.log(turn.finalResponse); // the agent's last message
console.log(turn.usage); // input, cached input, cache-write input, output, reasoning output tokens
console.log(thread.id); // store this to resume later

run() waits for the whole turn, then resolves to { items, finalResponse, usage }. items holds everything the agent did, as typed objects: command_execution (with command, aggregated_output and exit_code), file_change (with the changed paths), mcp_tool_call, web_search, todo_list, reasoning, error and agent_message. Those items are what your verification code inspects.

Leave model unset so the thread uses Codex’s default model (GPT-6.1 Sol on 0.162.1). Tune modelReasoningEffort before switching models; the models hub lists the options and prices. For sandbox values, the SDK exposes the legacy sandboxMode flag (read-only, workspace-write, danger-full-access). OpenAI now prefers permission profiles (beta), which you can pass through the Codex constructor’s config option, for example new Codex({ config: { default_permissions: ":workspace" } }). config exists only on the constructor, so a profile applies to every thread of that Codex instance. Pick one system per Codex instance: OpenAI says the profile and legacy sandbox systems “do not compose”, so if you use a profile, drop sandboxMode and networkAccessEnabled from the thread options. Network access is then whatever the selected profile allows, so check its definition (a named profile lives under [permissions] in config.toml). The examples on this page stay on the legacy sandboxMode and networkAccessEnabled pair because it gives each thread an explicit network-off switch. Never use danger-full-access outside a disposable, network-restricted container.

Call run() again on the same Thread object to continue the conversation. The agent keeps the context of the earlier turns, so the second prompt can say “the fix you described” without repeating it.

Make the result checkable with structured output

Section titled “Make the result checkable with structured output”

Free text is fine for a human and useless for a gate. Pass a JSON Schema as outputSchema and the agent’s final message becomes JSON in that shape. A TypeScript cast does not check that at runtime, so validate the parsed object before any gate reads it: on a turn that ended oddly, a missing risk would otherwise read as undefined and pass as “not high”. The SDK writes the schema to a temporary file and passes it to the CLI as --output-schema. The schema must be a plain object, or run() throws outputSchema must be a plain JSON object.

const fixReport = {
type: "object",
properties: {
root_cause: { type: "string" },
files_changed: { type: "array", items: { type: "string" } },
tests_passed: { type: "boolean" },
risk: { type: "string", enum: ["low", "medium", "high"] },
},
required: ["root_cause", "files_changed", "tests_passed", "risk"],
additionalProperties: false,
} as const;
const fix = await thread.run(
"Implement the smallest fix for the root cause you found. Run `npm test` after the change. " +
"Report the files you changed and whether the full suite passed.",
{ outputSchema: fixReport, signal: AbortSignal.timeout(20 * 60 * 1000) }
);
type FixReport = {
root_cause: string; files_changed: string[]; tests_passed: boolean; risk: "low" | "medium" | "high";
};
// A cast checks nothing at runtime: validate the shape before the gate relies on it.
function parseReport(text: string): FixReport {
const r = JSON.parse(text) as Partial<FixReport> | null; // throws on text that is not JSON
if (
typeof r?.root_cause !== "string" ||
!Array.isArray(r.files_changed) || !r.files_changed.every((f) => typeof f === "string") ||
typeof r.tests_passed !== "boolean" ||
!["low", "medium", "high"].includes(String(r.risk))
) {
throw new Error("Rejected: final response does not match fixReport");
}
return r as FixReport;
}
const report = parseReport(fix.finalResponse);

The signal option cancels a turn that runs past your budget. Twenty minutes is an example; set it from your own job history.

A turn also takes cyberAccessProgram, which the SDK passes to the CLI as --cyber-access-program. Its values are "standard", "daybreak_blue" and "daybreak_red"; it is experimental, applies to the built-in OpenAI provider, and you omit it to use server defaults (dist/index.d.ts in the @openai/codex-sdk 0.162.1 npm package, read 2026-10-13; added in CLI 0.161.0).

Verify the agent’s work without reading every line

Section titled “Verify the agent’s work without reading every line”

The report is the agent’s claim. Your gate checks the claim against evidence the agent cannot fake: the Git diff of the worktree, the typed items from the turn and a test run your own code starts. Run it before anything leaves the job’s worktree.

import { execFileSync } from "node:child_process";
import path from "node:path";
import type { FileChangeItem, CommandExecutionItem } from "@openai/codex-sdk";
const repo = "/srv/jobs/4812/repo";
const ALLOWED = ["src/", "test/"];
const git = (...args: string[]) => execFileSync("git", args, { cwd: repo }).toString();
const lines = (s: string) => s.split("\n").filter(Boolean);
const rel = (p: string) => path.relative(repo, path.resolve(repo, p));
// `base` is the commit recorded before the first turn (see the first thread example):
// if the agent or a hook commits during the run, HEAD moves and a diff against it hides those changes.
// Ground truth comes from Git, not from the items: edits made by shell commands
// (sed -i, npm install rewriting the lockfile, a codegen script) never appear as file_change.
git("add", "-A");
const touched = lines(git("diff", "--cached", "--name-only", base));
const outOfScope = touched.filter((f) => !ALLOWED.some((dir) => f.startsWith(dir)));
const unreported = touched.filter((f) => !report.files_changed.map(rel).includes(f));
if (outOfScope.length || unreported.length) {
throw new Error(`Rejected: out of scope ${outOfScope}, unreported ${unreported}`);
}
// Paths in the diff that no file_change item reported were changed by a command: record them.
const viaFileChange = new Set(
fix.items
.filter((i): i is FileChangeItem => i.type === "file_change" && i.status === "completed")
.flatMap((i) => i.changes.map((c) => rel(c.path)))
);
const viaShell = touched.filter((f) => !viaFileChange.has(f));
const failedCommands = fix.items.filter(
(i): i is CommandExecutionItem => i.type === "command_execution" && i.status === "failed"
);
// An existing test the agent modified, deleted or renamed can hide a failure: route it to a human.
const editedTests = lines(git("diff", "--cached", "--name-status", base))
.map((l) => l.split("\t"))
.filter(([status, file]) => /^[MDR]/.test(status) && file.startsWith("test/"))
.map(([, file]) => file);
// Do not trust tests_passed: run the suite yourself, with a fixed command.
// The suite runs code the agent wrote, so strip the API key from its environment.
const { CODEX_API_KEY: _key, ...testEnv } = process.env;
execFileSync("npm", ["test"], { cwd: repo, stdio: "inherit", env: testEnv }); // throws on a non-zero exit
// Size limit: a "minimal fix" that rewrites 400 lines goes to a human.
const stat = git("diff", "--cached", "--shortstat", base);
const MAX_LINES = 150;
const linesChanged = (s: string) =>
[...s.matchAll(/(\d+) (?:insertion|deletion)/g)].reduce((n, m) => n + Number(m[1]), 0);
const needsHuman =
linesChanged(stat) > MAX_LINES || report.risk === "high" || editedTests.length > 0; // route these to a reviewer

What this gate proves, and who signs off:

  • Scope. Every path in the staged Git diff is inside the allowed directories and appears in the report. The diff, not the file_change items, is the source, because a file changed by a shell command (sed -i, npm install rewriting package-lock.json, a codegen script) never appears as a file_change item. The diff runs against base, the commit recorded before the first turn, not against HEAD: an agent or a Git hook that commits during the run moves HEAD, and a diff against it would hide those changes. An unreported edit is a hard reject, because it means the report is not a faithful summary. Record viaShell, the paths no file_change item covers, so a reviewer can see which edits came from commands.
  • Behaviour. Your code runs the fixed test command, never a command the agent suggested, and runs it without CODEX_API_KEY in its environment, because the suite executes code the agent just wrote. Run it in the same network-restricted container as the agent if the job has any other secret. A green tests_passed with a red host run is a reject and a signal to look at the prompt. A green host run proves nothing if the agent weakened the assertion that was failing, so any existing file under test/ that the diff shows as modified, deleted or renamed sets needsHuman; newly added test files do not. For a stricter check, also run the originally failing tests with the base commit’s version of the test files.
  • Size. git add -A stages new files first, because git diff ignores untracked files and a patch that only adds files would report zero lines. Record the --shortstat output and route needsHuman jobs (above MAX_LINES, risk: "high", or an edited existing test) to a human reviewer; 150 lines is an example threshold. Record failedCommands too: a run that retried a failing migration five times deserves a look even if it ended green.
  • Sign-off. Low-risk, in-scope, green patches open a pull request that still goes through your normal review and CI. The gate decides what reaches a reviewer; it does not replace the merge approval. Attach the report, the item log and the host test output to the pull request as an evidence bundle.

run() returns only when the turn ends. For a dashboard or a log pipeline, runStreamed() returns { events }, an async generator of the same JSONL events that codex exec --json prints:

const { events } = await thread.runStreamed("Update the changelog for the fix you made.");
for await (const event of events) {
if (event.type === "thread.started") log({ threadId: event.thread_id });
if (event.type === "item.completed") log({ item: event.item.type, id: event.item.id });
if (event.type === "turn.completed") log({ usage: event.usage }); // token counts per turn
if (event.type === "turn.failed") log({ error: event.error.message });
}

Send turn.completed usage to the same place as your other job metrics. Input, cached input, cache-write input, output and reasoning output tokens per job are what Codex cost management needs to attribute spend to a pipeline rather than to a person.

thread.id is filled in once the first turn starts. Store it with the job, and a later stage (after a human approves the diagnosis, for example) can continue the same conversation:

const resumed = codex.resumeThread(savedThreadId, {
workingDirectory: "/srv/jobs/4812/repo",
sandboxMode: "workspace-write",
approvalPolicy: "never",
networkAccessEnabled: false,
webSearchMode: "disabled",
});
await resumed.run("The reviewer approved your diagnosis. Implement the fix.");

Pass the thread options again. They are CLI flags on each run, not stored with the thread, so a resume without them runs under the host’s config defaults. Thread state lives under CODEX_HOME (default ~/.codex: the sessions/ directory and its state databases) on the machine that ran them; a resume on a different container fails unless the whole CODEX_HOME is on a persistent volume.

A web endpoint that forwards req.body.prompt to thread.run() lets anyone who can reach it run commands on your server. If you build an internal “Fix this” button, keep the control in your code:

  • The caller picks a job type and a ticket ID, never a free-form prompt or a path. Your code maps the job type to a fixed prompt template and a repository from an allowlist.
  • Treat ticket text, issue bodies and CI logs as untrusted data. Quote them inside the prompt as material to analyse, and keep approvalPolicy: "never". With networkAccessEnabled: false, webSearchMode: "disabled" and no MCP servers in the job’s CODEX_HOME config, an injected instruction has no network path to fetch or exfiltrate through.
  • Run analysis-only jobs with sandboxMode: "read-only", and grant workspace-write only to the fix stage.
  • Queue jobs and give each its own worktree; do not run two threads in one directory.
  • Authenticate the endpoint, log thread.id with the requesting user, and let the gate above decide what becomes a pull request.

For the full issue-to-pull-request flow across tools, see the issue-to-PR pipeline.

What breaks when you run the Codex SDK in production?

Section titled “What breaks when you run the Codex SDK in production?”
SymptomCauseRecovery
run() throws Codex Exec exited with code 1: …The CLI itself failed: no credentials, not a Git repository, or a config key the CLI rejects. The message ends with the CLI’s stderrRead the stderr tail. Run codex login status on the host, check workingDirectory is a Git checkout, and test the same options with codex exec in a terminal
run() throws with the model’s error messageThe turn emitted turn.failed; the SDK turns that into an ErrorLog thread.id, then retry once on the same thread or resume it later. Repeated failures on one prompt usually mean a quota or context problem: see cost management
The CLI cannot find credentials or tools after you set envThe env option replaces the inherited environment entirely (the SDK only prepends its own bundled binary directories to PATH)Pass PATH and HOME (or CODEX_HOME) along with your own variables; the SDK still adds CODEX_API_KEY when you set apiKey
A turn runs for an hourNo timeout, and a loop of failing commandsPass signal: AbortSignal.timeout(…); inspect command_execution items with status: "failed" to find the loop
parseReport() throws: JSON.parse fails or the report does not match fixReportThe turn ended on an error message, or no outputSchema was passed on that turnCatch the error, send one follow-up on the same thread (“Return only the JSON object for the schema”), and reject the job if it fails again
resumeThread() fails or starts from nothingThe session files are on another machine, or the job ran with an ephemeral CODEX_HOMEPersist CODEX_HOME per job, or start a new thread with a summary of the previous stage
The agent edits the wrong files, or two jobs corrupt each otherShared working directory, or workingDirectory left unset (the SDK then uses the process’s current directory)One worktree per job, always pass workingDirectory, and keep the scope check in the gate
An injected instruction in ticket text still reaches the networknetworkAccessEnabled: false blocks only the agent’s shell commands; web search (webSearchMode) and MCP servers from the host’s config.toml are separate pathsSet webSearchMode: "disabled" on every thread and resume, and point CODEX_HOME at a per-job config that declares no MCP servers
Behaviour changes after npm updateSDK and CLI versions move together, and new CLI releases change defaultsPin the SDK exactly, and upgrade in its own pull request with the gate’s tests running