Skip to content

Exec Mode

Exec mode (also called headless or non-interactive mode) runs the AI agent with a single prompt, processes the response, and exits. No TUI, no interactive prompt loop. This is the interface for CI/CD pipelines, scripting, editor integrations, and any context where a human isn’t sitting at a terminal. The key design questions are: how is output structured for machine consumption, how are approvals handled without a human, and how does the agent signal success or failure?

Reference: references/aider/ at commit b9050e1d

Aider provides two flags for non-interactive execution, both in aider/main.py:

--message / -m (main.py:1126-1134):

if args.message:
io.add_to_input_history(args.message)
io.tool_output()
try:
coder.run(with_message=args.message)
except SwitchCoder:
pass
analytics.event("exit", reason="Completed --message")
return

The prompt is passed directly to coder.run(with_message=args.message), which calls run_one() internally and returns self.partial_response_content — the LLM’s response as a string. The process exits immediately after.

--message-file / -f (main.py:1136-1151):

Reads the prompt from a file and follows the same path as --message. This is useful for prompts that are too long for shell arguments or contain special characters.

Aider has no structured output mode. In --message mode, the LLM response is printed to stdout using the same Rich markdown rendering as interactive mode. There is no JSONL, no JSON event stream, no machine-parseable format. The --pretty false flag disables Rich formatting to produce plain text, which is the closest thing to machine-readable output.

The --yes-always flag auto-approves all confirmation prompts. Without it, --message mode will block on any confirmation prompt (file edits, shell commands) and hang until stdin provides input — which in a non-interactive context means it hangs forever. Aider does not automatically suppress approvals in message mode; the user must explicitly pass --yes-always.

Aider returns 0 on success and 1 on failure (e.g., message file not found). There is no structured exit code system — all LLM errors, API failures, and edit application failures are printed to stderr but don’t produce distinct exit codes.

  • No JSONL or JSON event stream
  • No structured output schema
  • No ephemeral session mode
  • No session resume/continue in message mode
  • No way to capture token usage or cost programmatically
  • No image attachment support in message mode

Reference: references/codex/ at commit 4ab44e2c

Codex has a dedicated exec subcommand with its own crate (codex-rs/exec/). This is architecturally separate from the TUI — they share the codex_core crate but have completely different presentation layers. The CLI is defined in codex-rs/exec/src/cli.rs.

Prompt Input:

  • [PROMPT] (positional, optional) — The prompt text. If omitted or set to -, reads from stdin (cli.rs:102-105)
  • --image FILE / -i FILE — Attach image(s) to the prompt (cli.rs:16-23)

Output Control:

  • --json — Print events as JSONL to stdout (cli.rs:89-96). Alias: --experimental-json
  • --output-last-message FILE / -o FILE — Write the final agent message to a file (cli.rs:98-100)
  • --output-schema FILE — Path to a JSON Schema for structured output validation (cli.rs:78-80)
  • --coloralways, never, or auto (cli.rs:85-87)

Execution Control:

  • --full-auto — Convenience alias: sets approval to on-request + sandbox to workspace-write (cli.rs:47-49)
  • --dangerously-bypass-approvals-and-sandbox (alias: --yolo) — No approvals, no sandbox (cli.rs:51-60)
  • --sandbox POLICY — Select sandbox policy explicitly (cli.rs:38-41)
  • --ephemeral — Don’t persist session files to disk (cli.rs:74-76)
  • --cd DIR — Set working directory (cli.rs:62-64)
  • --add-dir DIR — Additional writable directories (cli.rs:71-72)
  • --skip-git-repo-check — Allow running outside a Git repo (cli.rs:66-68)
  • --model MODEL / -m MODEL — Override model selection (cli.rs:25-27)
  • --profile NAME / -p NAME — Use a named config profile (cli.rs:43-45)
  • --oss — Use open-source local provider (cli.rs:29-31)

Subcommands:

  • exec resume — Resume a previous session (cli.rs:110-111)
  • exec review — Run a code review (cli.rs:113-114)

The output contract is enforced at the crate level (lib.rs:1-5):

// - In the default output mode, it is paramount that the only thing written to
// stdout is the final message (if any).
// - In --json mode, stdout must be valid JSONL, one event per line.
// For both modes, any other output must be written to stderr.
#![deny(clippy::print_stdout)]

The #![deny(clippy::print_stdout)] lint is a compile-time guarantee — no code in the exec crate can accidentally print to stdout. All human-readable output (progress, errors, logs) goes to stderr. This makes the exec output parseable by downstream tools regardless of verbosity.

When --json is active, EventProcessorWithJsonOutput (event_processor_with_jsonl_output.rs) translates internal codex_core::protocol::Event values into a stable JSONL schema. The top-level event enum is ThreadEvent (exec_events.rs:11-37), tagged with type:

pub enum ThreadEvent {
#[serde(rename = "thread.started")]
ThreadStarted(ThreadStartedEvent), // { thread_id }
#[serde(rename = "turn.started")]
TurnStarted(TurnStartedEvent), // {}
#[serde(rename = "turn.completed")]
TurnCompleted(TurnCompletedEvent), // { usage }
#[serde(rename = "turn.failed")]
TurnFailed(TurnFailedEvent), // { error }
#[serde(rename = "item.started")]
ItemStarted(ItemStartedEvent), // { item }
#[serde(rename = "item.updated")]
ItemUpdated(ItemUpdatedEvent), // { item }
#[serde(rename = "item.completed")]
ItemCompleted(ItemCompletedEvent), // { item }
#[serde(rename = "error")]
Error(ThreadErrorEvent), // { message }
}

Each ThreadItem carries a typed payload via ThreadItemDetails (exec_events.rs:100-128):

  • agent_message — LLM response text (or structured JSON when --output-schema is used)
  • reasoning — Reasoning summary text
  • command_execution — Shell command with aggregated output, exit code, and status (in_progress/completed/failed/declined)
  • file_change — Patch application with per-file change list and status
  • mcp_tool_call — MCP tool invocation with result or error
  • collab_tool_call — Multi-agent tool call
  • web_search — Web search query and results
  • todo_list — Agent’s running to-do plan with step statuses
  • error — Non-fatal error surfaced as an item

The turn.completed event includes Usage { input_tokens, cached_input_tokens, output_tokens } for programmatic cost tracking.

The --output-schema FILE flag loads a JSON Schema at startup and passes it through the agent loop as final_output_json_schema on each turn. The model’s response is validated against the schema and emitted as an agent_message item. This enables pipelines like:

Terminal window
codex exec "analyze this repo" --output-schema analysis.schema.json --json \
| jq 'select(.type == "item.completed") | .item | select(.type == "agent_message") | .text' \
| jq -r '.'

By default, exec sessions are persisted to JSONL rollout files (same as interactive sessions), making them resumable via codex exec resume. The --ephemeral flag suppresses this for one-shot CI runs where persistence adds overhead.

  • --full-auto: Approves commands automatically within the workspace-write sandbox
  • --dangerously-bypass-approvals-and-sandbox: Approves everything, no sandbox

In default mode (neither flag), exec blocks on approval prompts and renders them to stderr, expecting stdin input. This is intentional — it allows human-in-the-loop usage of exec mode in a terminal without a TUI.


Reference: references/opencode/ at commit 7ed44997

OpenCode’s non-interactive mode is the run CLI command, defined in packages/opencode/src/cli/cmd/run.ts (lines 215-618). Unlike Codex’s separate crate, OpenCode’s run command uses the same client-server architecture as the TUI — it starts an OpenCode server and communicates via the SDK.

Prompt Input:

  • [message..] (positional, array) — The prompt text, space-joined
  • --command NAME — Run a named command instead of a raw prompt
  • --file FILE / -f FILE — Attach file(s) to the message (array)

Output Control:

  • --format"default" (human-readable) or "json" (structured events) (run.ts:257-262)
  • --title NAME — Session title (auto-generated from prompt if not provided)

Session Control:

  • --continue / -c — Continue the last session
  • --session ID / -s ID — Continue a specific session by ID
  • --fork — Fork the session before continuing (requires --continue or --session)

Server Control:

  • --attach URL — Connect to an existing OpenCode server
  • --dir PATH — Working directory (remote path if attaching)
  • --port NUM — Local server port (random if not specified)
  • --model MODEL — Override model
  • --agent NAME — Override agent
  • --variant NAME — Model variant

When --format json is set, each event is emitted as a JSON line to stdout (run.ts:429-435):

function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {
process.stdout.write(JSON.stringify({
type,
timestamp: Date.now(),
sessionID,
...data
}) + EOL)
return true
}
return false
}

Event types include:

  • message.updated — Agent response started
  • message.part.updated — Individual message parts (text, tool calls, reasoning)
  • tool_use — Completed tool execution with tool name and output
  • text — Final text output
  • reasoning — Model thinking blocks
  • error — Session error

OpenCode’s run command takes a fundamentally different approach: it auto-denies all interactive prompts. The permission rules are hardcoded in run.ts:353-369:

const rules: PermissionNext.Ruleset = [
{ permission: "question", action: "deny", pattern: "*" },
{ permission: "plan_enter", action: "deny", pattern: "*" },
{ permission: "plan_exit", action: "deny", pattern: "*" },
]

This means the agent can never ask questions or enter plan mode during a run. Tool permissions (file edits, shell commands) follow the normal permission system and can be configured via the config file or --permission flags. There is no --yes-always equivalent — permissions must be explicitly set to "allow" for the tools the agent needs.

The run command creates a real session on the OpenCode server:

  1. If --continue or --session is specified, it reuses an existing session (optionally forking it)
  2. Otherwise, it creates a new session with the specified title and permission rules
  3. The session persists in SQLite like any interactive session
  4. After the prompt completes, the process exits (the server may stay running if --attach was used)

This means run-mode sessions appear in the TUI’s session list and can be continued interactively later.

The --attach URL flag connects to an already-running OpenCode server instead of starting a new one. This enables workflows where a background server handles multiple exec invocations without repeated startup costs. The SDK communicates via HTTP, so the “client” is just an HTTP client — no TUI rendering at all.


Codex’s #![deny(clippy::print_stdout)] lint is the right approach. In exec mode, stdout is the machine-readable channel. Any stray println! or logging that leaks to stdout breaks downstream JSON parsing. Aider violates this by printing Rich markdown to stdout in message mode — there’s no way to separate the LLM response from progress output without parsing the markdown.

Aider’s --yes-always approves everything. OpenCode’s run command denies questions and plan mode. These are opposite philosophies. For CI/CD, auto-deny is safer — the agent does what it can within pre-configured permissions and fails fast on anything unexpected. Auto-approve is more permissive but risks unintended side effects (running arbitrary shell commands without review). Codex’s --full-auto with workspace-write sandbox is the best middle ground: approve within a bounded sandbox.

Codex’s --ephemeral flag is important for CI. Without it, every exec invocation creates persistent session files that accumulate. OpenCode doesn’t have an ephemeral mode — run sessions always persist to SQLite. For batch processing (running hundreds of exec invocations), this creates database bloat and cleanup burden.

Both Codex and OpenCode read from stdin when no prompt is provided. This creates ambiguity: is the process waiting for stdin input, or did the user forget to pass a prompt? Codex handles this by checking std::io::stdin().is_terminal() and treating piped stdin differently from interactive stdin. But the UX is still confusing for users who expect a TUI to appear.

Codex’s JSONL event types are defined in exec_events.rs with #[derive(Serialize, Deserialize)] and ts-rs for TypeScript type generation. This creates a stable contract. OpenCode’s JSON events are ad-hoc JSON.stringify() calls with no schema — changing a field name is a silent breaking change for any downstream consumer.

Aider returns 0 or 1. Codex returns the process exit code from the underlying agent. Neither provides fine-grained exit codes (e.g., 2 for “approval denied”, 3 for “context overflow”, 4 for “API error”). This matters for CI pipelines that need to distinguish between “the agent couldn’t do it” and “the infrastructure is broken.”


Architecture: Separate openoxide-exec Crate

Section titled “Architecture: Separate openoxide-exec Crate”

Follow Codex’s approach: a dedicated openoxide-exec crate that shares openoxide-core with the TUI but has its own presentation layer. Enforce #![deny(clippy::print_stdout)] crate-wide. All human-readable output goes to stderr; all machine-readable output goes to stdout.

openoxide exec [PROMPT] [FLAGS]
openoxide exec resume [SESSION_ID | --last]
openoxide exec review [FLAGS]

Key flags:

  • --json — JSONL event output
  • --output-schema FILE — Structured output validation
  • --output FILE / -o FILE — Write final message to file
  • --full-auto — Auto-approve within workspace-write sandbox
  • --ephemeral — No session persistence
  • --model MODEL / -m MODEL
  • --image FILE / -i FILE
  • --quiet / -q — Suppress stderr progress output

Define a stable event enum with serde tagging, following Codex’s ThreadEvent pattern:

#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type")]
pub enum ExecEvent {
#[serde(rename = "session.started")]
SessionStarted { session_id: String },
#[serde(rename = "turn.started")]
TurnStarted {},
#[serde(rename = "turn.completed")]
TurnCompleted { usage: TokenUsage },
#[serde(rename = "item.started")]
ItemStarted { item: ExecItem },
#[serde(rename = "item.updated")]
ItemUpdated { item: ExecItem },
#[serde(rename = "item.completed")]
ItemCompleted { item: ExecItem },
#[serde(rename = "error")]
Error { message: String },
}

Generate JSON Schema from the enum using schemars for downstream consumers.

Define meaningful exit codes:

  • 0 — Success
  • 1 — Agent error (LLM refused, edit application failed)
  • 2 — Approval denied (agent needed approval that wasn’t pre-configured)
  • 3 — Context overflow (prompt too large for model)
  • 4 — API error (network, auth, rate limit)
  • 5 — Configuration error (invalid config, missing API key)

Default to Codex’s --full-auto behavior: approve within a workspace-write sandbox. Require explicit --dangerously-bypass-sandbox for full access. Auto-deny interactive questions (OpenCode’s approach) — the agent should work with what it has, not ask questions in a non-interactive context.

  • openoxide-exec — The exec binary/crate
  • openoxide-core — Shared agent logic
  • serde_json — JSONL serialization
  • schemars — JSON Schema for event types
  • supports-color — ANSI color detection for stderr output