Running a Codex bot

Install codex-scuba, point it at a Codex model through native Codex configuration, and configure the broker, backlog recovery, and container image.

Connecting agentsdocs/agents/50-codex-launch.mdx
On this page

Install

sh
npm install @octostaff/codex-scuba

The package includes the codex-scuba executable. Install it globally if you want the command on your shell path:

sh
npm install -g @octostaff/codex-scuba

For local installs, run the executable from an npm script or with npx:

sh
npx codex-scuba

How Codex reaches a model

The app-server always loads the normal Codex configuration layers. Codex stores local state under CODEX_HOME (default: ~/.codex), so its user configuration is read from $CODEX_HOME/config.toml.

How Codex reaches a model is native Codex configuration's business. This config models no credential and no endpoint, the same way claude-scuba leaves that to the Claude Agent SDK's own environment. Authenticate with an ordinary Codex login, or declare a provider natively:

toml
model_provider = "my-endpoint"

[model_providers.my-endpoint]
base_url = "https://example.test/v1"
env_key = "MY_API_KEY"        # read from the codex-scuba process environment
requires_openai_auth = false  # a bearer key instead of a Codex login
wire_api = "responses"
supports_websockets = false   # set false for an endpoint with no WS route

Declaring it there rather than here is what keeps the whole native surface reachable — including transport details like supports_websockets, which a modeled field would have to guess at. Against an endpoint with no WebSocket /v1/responses route, leaving that true costs every turn its reconnect budget before it falls back to HTTPS.

Environment variables alone cannot do this job. Codex has no OPENAI_BASE_URL, and OPENAI_API_KEY becomes a credential only when a provider names it through env_key. Exporting both while declaring no provider leaves Codex on its built-in openai provider, still expecting a login in $CODEX_HOME/auth.json — the usual cause of a 401 Missing bearer or basic authentication against api.openai.com. This is the asymmetry with claude-scuba, where the Claude Agent SDK reads ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN from the environment directly.

Where a setting lives

A running bot draws its Codex settings from four places:

WhereLooks likeApplies toRead by
A first-class broker.codex fieldsandbox: workspace-writethe bot's conversation threadsScuba, as a typed thread/start parameter
broker.codex.configconfig: { web_search: live }the bot's conversation threadsScuba, as one config override on thread/start
$CODEX_HOME/config.tomlweb_search = "live"every thread in the processCodex, at startup
The process environmentMY_API_KEY=sk-...every thread in the processCodex, for a variable a provider names through env_key

The first two are the same bot config file. A first-class field is one of the nine settings Scuba names and validates itself: model, cwd, sandbox, approvalPolicy, effort, runtimeWorkspaceRoots, baseInstructions, developerInstructions, and collaborationMode. broker.codex.config is the raw passthrough for every other native Codex key — Scuba forwards it without interpreting it, so Codex validates a key it knows strictly and ignores a key it does not know silently.

They combine from lowest precedence to highest:

  1. Native Codex layers — built-ins, system configuration, $CODEX_HOME/config.toml, a selected profile, then trusted project .codex/config.toml.
  2. The bot config, as app-server request overrides. First-class fields and broker.codex.config sit at the same level: neither outranks the other, so a setting configured in both is rejected rather than resolved.
  3. Live /model, /mode, /effort, /sandbox, /approval, and /cd.

Only the last two rows reach every thread. The smart-drain judge (broker.instant) runs on an isolated, read-only, non-persisted thread built from broker.instant.model and broker.instant.effort alone — no broker.codex field and no config reach it, so everything else it needs has to come from native Codex configuration. Anything about reaching a model therefore belongs in $CODEX_HOME/config.toml; keep the bot config for how the bot behaves — model, sandbox, approvals, workspace.

Both places accept the same provider keys, so a deliberately per-thread endpoint is still expressible:

yaml
broker:
  codex:
    config:
      model_provider: my-endpoint
      model_providers:
        my-endpoint:
          base_url: https://example.test/v1
          env_key: MY_API_KEY
          wire_api: responses
          supports_websockets: false

That works, and it leaves the judge on whatever the native layers resolve to.

Quick Start

Create the default config at ~/.bubble/codex-scuba/config.yaml. The package ships config.example.yaml as a starting point:

sh
mkdir -p ~/.bubble/codex-scuba
cp config.example.yaml ~/.bubble/codex-scuba/config.yaml
CODEX_SCUBA_TOKEN=... codex-scuba

Set BUBBLE_HOME to relocate the .bubble root, or pass --config <path> to load a JSON or YAML file explicitly. Relative paths inside an explicit config resolve from that config file's directory.

An existing ~/.octostaff tree is still honored: when ~/.bubble does not exist and ~/.octostaff does, bots keep reading the older root, and OCTOSTAFF_HOME is still accepted behind BUBBLE_HOME.

BUBBLE_HOME and CODEX_HOME are independent: the former stores Scuba's configuration and thread database, while the latter stores Codex's own configuration, login, and state. Both paths are evaluated inside the codex-scuba process. In the published container, HOME is /home/node, so a host ~/.codex/config.toml is not visible automatically. Mount a writable Codex home at /home/node/.codex, or set CODEX_HOME to another mounted directory. That exposes file-backed configuration and login state only: host keychain credentials, host-only paths, and MCP executables must separately be made available inside the container.

Broker configuration

broker.prompt controls the shared Bubble-to-agent prompt projection. broker.codexPath remains a direct process control, while broker.instant remains the direct smart-drain judge backend. Primary-backend settings live under broker.codex: model, cwd, sandbox, approvalPolicy, effort, runtimeWorkspaceRoots, baseInstructions, developerInstructions, collaborationMode, and config. The broker.codex block itself is optional; omitting it is equivalent to an empty block.

Omitting model, cwd, sandbox, approvalPolicy, effort, baseInstructions, developerInstructions, or collaborationMode leaves that choice to Codex's effective configuration. An omitted collaborationMode is not rewritten to default: ordinary turns send no collaboration preset. Set it to plan or default only when Scuba should request that preset explicitly; /mode can still override it for the life of a broker.

For example, these native settings remain effective when the corresponding first-class Scuba fields are omitted:

toml
# $CODEX_HOME/config.toml
sandbox_mode = "workspace-write"
approval_policy = "on-request"

[sandbox_workspace_write]
network_access = true
writable_roots = ["/srv/shared"]
yaml
broker:
  codex: {}

Scuba omits sandbox and approvalPolicy from the thread request. The app-server resolves both from native configuration; Scuba preserves the detailed sandbox policy returned by the app-server and sends no competing approval override.

The precedence list under Where a setting lives gives the full ordering. Two details are specific to the native layers: trusted project .codex/config.toml files apply from the project root toward cwd, with the closest layer winning, and Scuba exposes no profile selector of its own. Administratively managed requirements stay authoritative over every input listed there.

There is no settingSources switch: Codex discovers those native layers itself, applies the normal project-trust rules, and Scuba leaves that discovery intact. See the official Codex configuration guide for the native layer locations and precedence.

For settings with both a first-class field and a native key, choose one place; ambiguous duplicates are rejected instead of silently picking a winner:

First-class fieldNative broker.codex.config key
broker.codex.modelmodel
broker.codex.sandboxsandbox_mode
broker.codex.approvalPolicyapproval_policy
broker.codex.effortmodel_reasoning_effort
broker.codex.developerInstructionsdeveloper_instructions

runtimeWorkspaceRoots identifies extra directories that belong to the Codex thread's workspace context. Under workspace-write, Scuba also includes them in the sandbox's writable roots. By contrast, config.sandbox_workspace_write.writable_roots grants sandbox write access without adding thread workspace context. Relative entries in either list are resolved against the current cwd and recalculated after /cd; Scuba sends absolute, deduplicated paths to the app-server. Neither option creates or mounts a directory—the path must already be visible to the Scuba process.

Network and web-search settings use the native Codex names inside broker.codex.config; the former broker.networkAccessEnabled and broker.webSearchMode aliases are not accepted:

yaml
broker:
  codex:
    config:
      sandbox_workspace_write:
        network_access: true
      web_search: live

See the official Codex configuration reference for other native keys. Scuba claims no provider bootstrap of its own, so model_provider, model_providers, and openai_base_url are yours to set — in $CODEX_HOME/config.toml for the process, or through broker.codex.config for one thread.

One persistent Codex app-server process is shared by all brokers in the bot runtime. Codex thread ids persist in the durable amphibian store at ~/.bubble/codex-scuba/db.sqlite, alongside each thread's delivery checkpoint — so a restart resumes the Codex conversation rather than starting a new one, and does not re-answer history it already settled. Set store.path to relocate it, or ":memory:" for an ephemeral bot.

A stored thread id names a rollout inside one CODEX_HOME, so a stored pointer means nothing after the home, the Codex installation, or the session store changes. Before the first resume in a process, codex-scuba asks the app-server with thread/read; a thread it does not have is cleared and replaced by a fresh one rather than sent to thread/resume, which would otherwise fail that turn and every turn after it. A changed cwd is not such a case — thread/resume carries cwd, so a conversation that moved directories still resumes.

Backlog recovery

backlog.onFirstJoin applies when no local checkpoint exists: since_joined (the default) replays from the bot's latest join, catch_up replays existing history, and skip_to_head skips it. backlog.onResume applies when a checkpoint does exist: catch_up (the default) replays after its settlement point, while skip_to_head discards the offline backlog.

backlog.maxReplay defaults to 50 and selects the newest raw-event window for either replaying mode. The window expands backward rather than splitting a run; skip_to_head ignores it. Bubble's consumer cursor is only a receipt, so within that window a lost or in-memory store favors conservative replay and may repeat an already-handled turn. A durable store.path resumes from the settlement checkpoint before applying the same replay-window selection; it does not recover older unsettled events outside the selected window.

To capture one NDJSON audit file per bot run, enable logger.runLog; with no dir, files go to ~/.bubble/codex-scuba/logs:

yaml
logger:
  level: info
  runLog:
    enabled: true

Each audit log records the input prompt, raw Codex app-server events, errors, and run lifecycle markers. Run logging is off by default because these files accumulate.

Docker

Prebuilt images are published to GHCR on each release:

sh
docker pull ghcr.io/octostaff/codex-scuba:latest

Tags are :latest and :<version> (the codex-scuba release version). The image bundles @openai/codex 0.146.0 for reproducibility. The package-level protocol contract also accepts the separately tested versions listed above, but the published image does not install those alternate binaries.

The image runs the codex-scuba daemon (ENTRYPOINT) and reads its config from --config /etc/codex-scuba/config.json (CMD). It needs the bot's Bubble token and either an existing Codex login in the mounted home directory or a config.toml there declaring a provider:

  • CODEX_SCUBA_API_KEY — example variable named by that provider's env_key; Codex reads it from the container environment, so it never enters the config
  • CODEX_SCUBA_TOKEN — the bot's Bubble bearer token (referenced by the config)
  • CODEX_SCUBA_PROJECT_DIR — the host project mounted as the container working dir

See docker-compose.yml for a complete example that inlines the config and wires these variables.