Skip to content

Run Codex non-interactively with codex exec

codex exec runs Codex without the interactive terminal UI: it prints progress to stderr and the agent’s final message to stdout, never stops to ask for approval, and exits non-zero when the run errors. It is the building block for scripts, cron jobs and CI steps, provided the calling script, not the agent, checks the result.

This page is for developers and tech leads who already use Codex interactively and now want it inside a pipeline. The usual situation: a nightly job should triage failing tests, a pre-merge step should return a machine-readable verdict, or a release script needs notes in a fixed shape. A chat transcript is useless to a shell script. You need exact flags, output a parser can trust, and a gate that fails loudly when the agent claims success it did not earn.

  • The codex exec invocations for read-only analysis, workspace edits, schema-checked JSON and multi-stage runs, checked against Codex CLI 0.157.1.
  • A strict JSON Schema and jq recipes that turn the JSONL event stream into pass/fail signals.
  • A GitHub Actions job that runs Codex on a trusted branch, keeps the API key away from the agent’s shell, and verifies the change without anyone reading the diff line by line.
  • A failure-modes list with the exact error text and the fix for each.

How does codex exec differ from interactive Codex?

Section titled “How does codex exec differ from interactive Codex?”

The same agent runs underneath, but the defaults change because no one is at the keyboard. These behaviours were read from the codex exec --help output and the codex-rs/exec source of Codex CLI 0.157.1.

BehaviourInteractive codexcodex exec
OutputFull-screen terminal UIProgress on stderr, final message on stdout; --json switches stdout to JSONL events
ApprovalsAsks according to approval_policyNever asks. A command that needs approval outside the sandbox is refused instead
SandboxFrom config.toml or /permissionsFrom --sandbox, -c default_permissions=… or config.toml. Read-only unless you grant more
Git checkNo Git requirementExits 1 outside a Git repository unless you pass --skip-git-repo-check
Session filesSaved, resumable with codex resumeSaved, resumable with codex exec resume, unless you pass --ephemeral
Prompt sourceTypedArgument, - for stdin, or both: piped stdin is appended to the prompt as a <stdin> block

codex exec also has an alias, codex e, and a sibling for reviews, codex exec review, covered later on this page.

  1. Authenticate. On your own machine, codex login is enough; codex exec reuses the stored login. In CI, set CODEX_API_KEY from a secret store instead. codex exec reads that variable directly, so no credential file is written to the runner. See Codex authentication for the sign-in options.

  2. Start read-only. With no sandbox flag, Codex can read the repository and run read-only commands, but it cannot edit files.

    Terminal window
    codex exec "List the five modules with the most complex control flow and say why each is risky."
  3. Feed it context through a pipe. When stdin is piped and a prompt is given, Codex appends stdin to the prompt. Use this for diffs, logs and test output.

    Terminal window
    git diff origin/main...HEAD | codex exec "Summarise this diff as release notes: Features, Fixes, Breaking changes."
  4. Grant write access only when the task edits files. --sandbox workspace-write lets Codex edit the working tree; network access stays off.

    Terminal window
    codex exec --sandbox workspace-write "Fix the failing test in tests/billing/invoice.test.ts. Change only src/billing/."
  5. Capture the answer in a file. -o (--output-last-message) writes the agent’s final message to a file, so the script does not have to scrape stdout.

    Terminal window
    codex exec -o triage.md "Read the last CI log in ci.log and name the root cause in three sentences."

In CI, also pin the model with -m so a change of default does not silently alter a nightly job’s behaviour. The current default model and its effort levels are listed on the models hub; set effort per run with -c model_reasoning_effort="high" when a task needs it.

Choose the permissions for an unattended run

Section titled “Choose the permissions for an unattended run”

Pick the narrowest option that lets the task finish. Because codex exec never asks, the choice you make here is the whole security decision.

Option (Codex CLI 0.157.1)What Codex can doUse it for
No flag (read-only)Read files, run read-only commandsTriage, analysis, reviews, reports
--sandbox workspace-writeEdit the working tree and --add-dir paths; no networkFixes, refactors, generated files
-c default_permissions=":workspace"A permission profile, the approach OpenAI now prefers for new integrations (beta, CLI 0.138.0 or later)New pipelines; do not combine with --sandbox or --approve-for-me, because OpenAI states the two systems “do not compose”
--approve-for-meRoutes approval requests to the automatic reviewer, inside the workspace-write sandboxJobs where a boundary request is expected and a model may decide it
--dangerously-bypass-approvals-and-sandboxEverything, with no sandboxOnly inside a disposable, network-restricted container that is itself the boundary

Every example on this page uses the legacy --sandbox workspace-write rather than a permission profile, for two reasons: profiles are still beta in 0.157.1, and OpenAI states that the profile and legacy sandbox systems “do not compose”, so one command should use one system only. A new pipeline can adopt profiles by replacing the flag with -c default_permissions=":workspace" in every command; do not keep both.

Two more flags make a CI run reproducible. --ignore-user-config skips $CODEX_HOME/config.toml, so a runner’s leftover MCP servers or model settings cannot change the job. --strict-config fails the run when config.toml contains a key this Codex version does not recognise, which catches a typo before it becomes a silent default. Configuration keys and layering are explained in Codex configuration.

--output-schema takes a JSON Schema file and constrains the agent’s final message to it. Codex sends the schema with strict validation switched on (strict: true in the 0.157.1 source), so the schema itself has to follow strict-mode rules: every object lists all its properties in required and sets "additionalProperties": false, at every level. Express an optional field as a nullable type instead of leaving it out of required.

Save this as .github/codex/risk-schema.json:

{
"type": "object",
"properties": {
"verdict": { "type": "string", "enum": ["pass", "fail"] },
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"file": { "type": "string" },
"line": { "type": ["integer", "null"] },
"severity": { "type": "string", "enum": ["high", "medium", "low"] },
"evidence": { "type": "string" }
},
"required": ["file", "line", "severity", "evidence"],
"additionalProperties": false
}
}
},
"required": ["verdict", "findings"],
"additionalProperties": false
}

Then run it and let jq -e turn the answer into an exit code:

Terminal window
codex exec --output-schema .github/codex/risk-schema.json -o risk.json \
"$(cat .github/codex/risk-prompt.md)"
jq -e '.verdict == "pass" and ([.findings[] | select(.severity == "high")] | length == 0)' risk.json

The schema guarantees the shape, not the truth. A "verdict": "pass" means the agent believed it; the tests and checks in the verification section below decide whether you do.

--json replaces the human-readable stdout with one JSON object per line. In Codex CLI 0.157.1 the top-level type values are thread.started, turn.started, turn.completed, turn.failed, item.started, item.updated, item.completed and error. Items carry their own type: agent_message, reasoning, command_execution, file_change, mcp_tool_call, collab_tool_call, web_search, todo_list and error.

An abridged run looks like this (values are illustrative):

{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"npm test","aggregated_output":"...","exit_code":1,"status":"failed"}}
{"type":"item.completed","item":{"id":"item_4","type":"agent_message","text":"The invoice test fails because rounding happens before tax."}}
{"type":"turn.completed","usage":{"input_tokens":24763,"cached_input_tokens":19200,"cache_write_input_tokens":0,"output_tokens":122,"reasoning_output_tokens":64}}

Save the stream once, then query it for what the pipeline needs:

Terminal window
codex exec --json "Diagnose the failing tests in ci.log" > events.jsonl
# The session ID, for a later resume
jq -r 'select(.type == "thread.started") | .thread_id' events.jsonl
# The final answer
jq -r 'select(.type == "item.completed" and .item.type == "agent_message") | .item.text' events.jsonl | tail -n 1
# Every command the agent ran that exited non-zero
# (declined commands carry exit_code null, so the null check skips them)
jq -r 'select(.type == "item.completed" and .item.type == "command_execution" and .item.exit_code != null and .item.exit_code != 0) | .item.command' events.jsonl
# Which files it changed
jq -r 'select(.type == "item.completed" and .item.type == "file_change") | .item.changes[].path' events.jsonl
# Output tokens for the whole run, for cost tracking
jq -s '[.[] | select(.type == "turn.completed") | .usage.output_tokens] | add' events.jsonl

Keep events.jsonl as a build artifact. It is the audit trail of what the agent actually ran, which matters more than what its final message says it ran.

Split long work into stages so that each stage has one job and your script can check it before the next one starts. codex exec resume continues a saved session; codex exec fork copies one into a new session, so you can try two approaches from the same analysis.

Terminal window
# Stage 1: analyse, read-only, and record the session ID
codex exec --json "$(cat plan-prompt.md)" > stage1.jsonl
THREAD_ID=$(jq -r 'select(.type == "thread.started") | .thread_id' stage1.jsonl)
# Stage 2: implement in the same session, now with write access
codex exec --sandbox workspace-write resume "$THREAD_ID" \
"Implement step 1 of your plan only. Run the tests you listed. Stop after that."
# Alternative: branch the analysis into a separate session, also with write access
codex exec --sandbox workspace-write fork "$THREAD_ID" \
"Implement the plan with the smallest possible diff instead."

Note where --sandbox goes. Neither codex exec resume nor codex exec fork has a --sandbox option of its own in 0.157.1 (unexpected argument '--sandbox' found), so the flag sits before the subcommand. A fork without it inherits the read-only default and cannot edit anything. Prefer the explicit session ID over resume --last: --last picks the newest session recorded for the current directory, which is the wrong one as soon as two jobs share a runner. A run started with --ephemeral writes no session files, so it cannot be resumed at all.

Review a diff headlessly with codex exec review

Section titled “Review a diff headlessly with codex exec review”

For review-only jobs, codex exec review runs Codex’s built-in reviewer. You give it either a preset target (uncommitted changes with --uncommitted, a base branch with --base, or one commit with --commit) or custom review instructions as the prompt, not both. In Codex CLI 0.157.1, --base, --uncommitted and --commit cannot be combined with custom review instructions: the command fails at argument parsing with cannot be used with '[PROMPT]'.

A preset target, with the built-in review instructions:

Terminal window
codex exec review --base main --json -o review.md > review-events.jsonl

Custom instructions, with the diff range named inside the instructions instead of in a flag:

Terminal window
codex exec review -o review.md "$(cat .github/codex/review-prompt.md)"

Both forms accept --json and -o. The -o file is a human-readable report: an overall explanation followed by prioritised findings. In 0.157.1 the built-in reviewer runs under its own rubric and fixed output format, so your instructions change what it looks for, not the shape of what it returns. Do not grep that report for a pass or fail line; nothing guarantees one. --output-schema is accepted at parse time, but in the 0.157.1 source the review request carries no schema. Any machine gate belongs on a plain codex exec with --output-schema and the same review prompt, as in the JSON output section; use codex exec review for the report a human reads.

How review fits into a team’s pull request flow, including GitHub-side review, is covered in code review with Codex.

Verify the result without reading every line

Section titled “Verify the result without reading every line”

The agent’s final message is a claim. The pipeline’s job is to check it with signals the agent cannot fake, and to hand a human a short evidence summary rather than a raw diff. Run these checks in the script after codex exec returns, as separate steps:

  1. Exit code. codex exec exits 1 when the run errors (bad schema file, Git check, failed turn). Treat that as a hard failure. Exit 0 only means the run finished, not that the task succeeded.
  2. Re-run the tests yourself. Do not trust "tests pass" in the output. Run the suite in a fresh step with the job’s own command. If the agent edited a test to make it pass, the next check catches it.
  3. Protected paths. Write events.jsonl and the -o file outside the checkout (for example under $RUNNER_TEMP), so they are not staged with the agent’s change and do not count against the diff budget. Stage everything with git add -A (so new files count), then fail the job if the change touches files the agent must not edit: git diff --cached --name-only | grep -qE '^(tests/|\.github/|migrations/|package(-lock)?\.json$)' && exit 1. package.json is on the list because the agent could otherwise redefine the test script that the next check runs.
  4. Diff budget. Cap the change size so a “one-line fix” cannot quietly become a rewrite: git diff --cached --numstat | awk '{s+=$1+$2} END {exit (s > 200)}'.
  5. Schema verdict. jq -e on the -o file, as shown above.
  6. Evidence for the human. Attach events.jsonl, the -o file and the test output to the pull request. The reviewer checks that the evidence matches the intent; the evidence bundle page defines what that pull request must prove.

Who signs off stays the same as for human changes: the code owner of the touched paths approves the pull request, and nothing the job produces merges without it.

In GitHub Actions, the official openai/codex-action@v1 is the first choice: it keeps the key behind a proxy and can drop sudo before Codex starts. Its inputs are covered in CI/CD with the Codex GitHub Action. Call codex exec directly when you need a flag the action does not expose, or when you run on another CI system.

Two rules make the direct call safe. First, run it only on trusted code: a schedule or manual trigger on the default branch, never on a contributor’s ref while a secret is in the environment. Second, keep the key out of the agent’s shell. In Codex CLI 0.157.1 the commands Codex runs inherit the full environment by default (shell_environment_policy.ignore_default_excludes defaults to true). Setting it to false filters out every variable whose name contains KEY, SECRET or TOKEN.

.github/workflows/codex-nightly.yml
name: codex-nightly-test-fix
on:
schedule:
- cron: '17 3 * * 1-5'
workflow_dispatch:
permissions:
contents: read
jobs:
fix:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- run: npm ci # install before Codex runs: workspace-write has no network
- run: npm install -g @openai/codex@0.157.1
- name: Run Codex
env:
CODEX_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_MODEL: gpt-6-astra
run: |
# Model pinned on purpose: the bundled default changes between CLI releases
codex exec -m "$CODEX_MODEL" --sandbox workspace-write --ephemeral --ignore-user-config \
-c shell_environment_policy.ignore_default_excludes=false \
--json --output-schema .github/codex/fix-schema.json -o "$RUNNER_TEMP/fix.json" \
"$(cat .github/codex/fix-prompt.md)" > "$RUNNER_TEMP/events.jsonl"
- name: Verify independently
run: |
STATUS=$(jq -r '.status' "$RUNNER_TEMP/fix.json")
git add -A
if [ "$STATUS" = "nothing_to_fix" ]; then
# A claim, not a pass: the tree must be untouched and the suite green.
git diff --cached --quiet || { echo "nothing_to_fix, yet files changed"; exit 1; }
npm test
exit 0
fi
if [ "$STATUS" != "fixed" ]; then echo "Codex status: $STATUS"; exit 1; fi
if git diff --cached --name-only | grep -qE '^(tests/|\.github/|migrations/|package(-lock)?\.json$)'; then
echo "Codex changed a protected path"; exit 1
fi
git diff --cached --numstat | awk '{s+=$1+$2} END {exit (s > 200)}'
npm test
git diff --cached > "$RUNNER_TEMP/codex.patch"
- uses: actions/upload-artifact@v7
if: always()
with:
name: codex-run
path: |
${{ runner.temp }}/events.jsonl
${{ runner.temp }}/fix.json
${{ runner.temp }}/codex.patch

The verify step treats nothing_to_fix as a claim too: it fails if the tree changed and still runs the suite. The job has read-only repository permissions and produces a patch artifact; opening the pull request is a separate, reviewed step described in CI/CD with the Codex GitHub Action. .github/codex/fix-schema.json follows the strict rules above, with status ("fixed", "nothing_to_fix", "not_fixed" or "needs_human"), summary, files_changed and tests_run, all required.

Not inside a trusted directory and --skip-git-repo-check was not specified. The working directory is not a Git repository. Run from the checkout root, or use -C <dir> to point at it. Add --skip-git-repo-check only for a scratch directory you created yourself, because without Git you lose the diff that the verification steps depend on.

Output schema file … is not valid JSON, or the API rejects the schema. The first is a syntax error in the file; validate it with jq . schema.json. The second is almost always strict mode: a property missing from required, or an object without "additionalProperties": false. Make every property required and use ["string", "null"] for optional values.

error: unexpected argument '--full-auto' found or unexpected argument '--sandbox' found. --full-auto is gone (see the caution above). --sandbox after resume or fork is not accepted; move it before the subcommand: codex exec --sandbox workspace-write resume <SESSION_ID> "…".

error: the argument '--base <BRANCH>' cannot be used with '[PROMPT]'. In 0.157.1, codex exec review takes a preset target (--base, --uncommitted, --commit) or custom review instructions, never both; the same error appears for --uncommitted and --commit. Drop the flag and name the diff range in the instructions, or drop the instructions and use the built-in review.

The agent reports that it could not write a file or install a package. The run is read-only by default, and workspace-write still has no network. Grant --sandbox workspace-write for edits (before resume or fork in a multi-stage run), and install dependencies in a step before codex exec. Do not reach for --dangerously-bypass-approvals-and-sandbox to get past this on a shared runner.

resume --last continues the wrong session. --last means the newest session for this directory, and parallel jobs on one runner race for it. Capture thread_id from the thread.started event and resume by ID. If the first stage used --ephemeral, there is nothing to resume; drop the flag on stages you plan to continue.

Exit code 0, but the task is not done. The exit code reports run errors, not task success. Gate on the schema verdict, the independent test run and the diff checks from the verification section, never on exit status alone.

The job behaves differently on a runner than on your laptop. A config.toml in the runner’s CODEX_HOME is adding MCP servers, a different model or rules. Add --ignore-user-config, pin the model with -m, and use --ignore-rules if user or project execpolicy .rules files should not apply to this job.

Hooks you expect to run do not. Codex hooks need persisted trust. --dangerously-bypass-hook-trust runs them without it, which is acceptable only when the job itself vets where the hooks come from, never for hooks in a contributor’s branch.