# Headless execution in Codex

Codex runs headless with `codex exec` or the `openai/codex-action` GitHub Action, taking an explicit sandbox, printing the final message or JSONL events, and authenticating with an API key.

Source: https://ai-sw-factory.mellicci.dev/fundamentals/headless-execution/codex

## At a glance

| | Codex |
|---|---|
| Term | `codex exec` (short form `codex e`), non-interactive mode |
| Configured in | Flags on the command, `~/.codex/config.toml`, or the `with:` inputs of `openai/codex-action@v1` |
| Loads / runs | Streams progress to `stderr`, prints the final message to `stdout`, then exits. Needs a Git repository |
| Scope & precedence | Sandbox defaults to read-only. Credentials: saved CLI login by default, `CODEX_API_KEY` in CI. `--ignore-user-config` skips `config.toml` |

## Build the scenario

The docs recommend `openai/codex-action` for GitHub Actions: it installs Codex, starts a proxy so steps do not see your API key, and runs `codex exec` with the settings you give it. The workflow below uses it; the `codex exec` form is in the [CI integration](#ci-integration) table.

| Step | What you write or run | Where |
|---|---|---|
| 1 | Add the secret `OPENAI_API_KEY` | Repository settings |
| 2 | Trigger on the label, set permissions and a time limit | `.github/workflows/agent-ready.yml` |
| 3 | Check out, install pnpm dependencies | Same file |
| 4 | Build the prompt, run Codex | Same file |
| 5 | Fail if nothing changed, push `agent/issue-<number>`, open the PR | Same file |

Trigger, permissions and time limit:

```yaml
name: agent-ready
on:
  issues:
    types: [labeled]
jobs:
  implement:
    if: github.event.label.name == 'agent-ready'
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: write
      pull-requests: write
      issues: read
    steps:
```

Setup (`persist-credentials: false` keeps the token out of `.git/config` while Codex runs):

```yaml
      - uses: actions/checkout@v5
        with:
          persist-credentials: false
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
```

Prompt from the issue. The text goes through environment variables, never into the shell script directly:

```yaml
      - name: Build prompt
        env:
          N: ${{ github.event.issue.number }}
          TITLE: ${{ github.event.issue.title }}
          BODY: ${{ github.event.issue.body }}
        run: |
          d=$(uuidgen)
          { echo "PROMPT<<$d"
            echo "Implement GitHub issue #$N in ticket-service: $TITLE"
            printf '\n%s\n\nRun pnpm test and pnpm lint before finishing.\n' "$BODY"
            echo "$d"; } >> "$GITHUB_ENV"
```

Codex run:

```yaml
      - name: Run Codex
        uses: openai/codex-action@v1
        with:
          openai-api-key: ${{ secrets.OPENAI_API_KEY }}
          prompt: ${{ env.PROMPT }}
          sandbox: workspace-write
          codex-args: '["-c","approval_policy=never"]'
          output-file: codex-output.md
```

Gate, push, open the PR. The step exits non-zero when Codex changed nothing, which fails the job:

```yaml
      - name: Push branch and open PR
        env:
          GH_TOKEN: ${{ github.token }}
          N: ${{ github.event.issue.number }}
        run: |
          git add -A -- . ':!codex-output.md'
          git diff --cached --quiet && { echo "No changes"; exit 1; }
          git -c user.name=codex-bot -c user.email=codex-bot@users.noreply.github.com \
            commit -m "Implement issue #$N"
          git push "https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.git" "HEAD:refs/heads/agent/issue-$N"
          { echo "Closes #$N"; echo; cat codex-output.md; } > body.md
          gh pr create --head "agent/issue-$N" --title "Implement issue #$N" --body-file body.md
```

## Specifics

### Running without a prompt

| Form | Behavior |
|---|---|
| `codex exec "<task>"` | Prompt as one argument |
| `cmd \| codex exec "<task>"` | The argument is the instruction; piped stdin is extra context |
| `cmd \| codex exec -` (or no argument) | Stdin is the whole prompt |
| `--skip-git-repo-check` | Codex requires a Git repository; this overrides the check |
| `--ephemeral` | Does not persist session rollout files |

### Output and exit codes

| Need | How |
|---|---|
| Final message only | Default: `stdout` has the final message, `stderr` has progress |
| Final message in a file | `-o <path>` / `--output-last-message <path>`; still printed to `stdout` |
| Every event | `--json`: `stdout` becomes JSONL (`thread.started`, `turn.started`, `turn.completed`, `turn.failed`, `item.*`, `error`) |
| Fixed shape | `--output-schema ./schema.json` (JSON Schema); pair with `-o` to save the JSON |
| In the action | Step output `final-message`, file from `output-file`; `--output-schema` through `codex-args` |

The documentation doesn't list exit codes for `codex exec`. It states one case: an enabled MCP server with `required = true` that fails to initialize makes `codex exec` exit with an error. Treat "non-zero fails the step" as the contract, and add your own gate, as the workflow does for "no changes".

### Permissions when nobody can approve

| Setting | Headless use |
|---|---|
| `--sandbox read-only` | Default for `codex exec` |
| `--sandbox workspace-write` | Allows edits; the scenario uses it (`sandbox:` in the action) |
| `--sandbox danger-full-access` | Only in an isolated runner or container |
| `approval_policy = "never"` | Documented as the choice for non-interactive runs; `-a never` is the global CLI flag |
| `--full-auto` | Deprecated, prints a warning |

The documentation doesn't show whether `codex exec` accepts `-a` or which approval policy it defaults to, so the workflow sets the policy with `-c`. Action inputs `safety-strategy` (default `drop-sudo`), `allow-users` and `allow-bots` limit privileges and who can trigger the run.

### Sessions and continuation

| Command | Effect |
|---|---|
| `codex exec resume --last "<task>"` | Continue the most recent session from this directory |
| `codex exec resume <SESSION_ID> "<task>"` | Continue a specific session |
| `--all` | Search sessions across directories, not only this one |
| `thread.started` event | Carries `thread_id` in `--json` output |

### CI integration

| | Action `openai/codex-action@v1` | `codex exec` in a `run` step |
|---|---|---|
| Use when | GitHub Actions (the docs' recommendation) | Other CI, or you manage the CLI yourself |
| Auth | `openai-api-key` input; proxy hides the key | `CODEX_API_KEY` on the one command |
| Inputs | `prompt` or `prompt-file`, `sandbox`, `model`, `effort`, `codex-args`, `output-file`, `codex-version`, `codex-home` | Flags above |
| Example | The workflow above | `CODEX_API_KEY=$KEY codex exec --sandbox workspace-write --json -o out.md "<task>"` |

Other auth options: `printenv OPENAI_API_KEY \| codex login --with-api-key`, or ChatGPT-managed auth for trusted runners (advanced, not for public repositories). The TypeScript SDK (`CODEX_API_KEY`, Node.js 18+) controls Codex programmatically.

## Gotchas

- Do not set `OPENAI_API_KEY` or `CODEX_API_KEY` as a job-level variable in a job that runs repository code; scope it to the Codex step.
- Issue text is untrusted input. The scenario passes it through environment variables, and the action by default only runs for users with write access.
- The docs' own pattern gives the Codex job read-only permissions and does the push in a second job without the key. The single job above follows the scenario's permissions instead.
- The default sandbox is read-only: without `sandbox: workspace-write` Codex cannot edit, and the run ends with no changes.
- `timeout-minutes` is a GitHub setting; the documentation doesn't describe a Codex-side run time limit.
