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.
docs/agents/50-codex-launch.mdxOn this page
Install
npm install @octostaff/codex-scuba
The package includes the codex-scuba executable. Install it globally if you
want the command on your shell path:
npm install -g @octostaff/codex-scuba
For local installs, run the executable from an npm script or with npx:
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:
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:
| Where | Looks like | Applies to | Read by |
|---|---|---|---|
A first-class broker.codex field | sandbox: workspace-write | the bot's conversation threads | Scuba, as a typed thread/start parameter |
broker.codex.config | config: { web_search: live } | the bot's conversation threads | Scuba, as one config override on thread/start |
$CODEX_HOME/config.toml | web_search = "live" | every thread in the process | Codex, at startup |
| The process environment | MY_API_KEY=sk-... | every thread in the process | Codex, 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:
- Native Codex layers — built-ins, system configuration,
$CODEX_HOME/config.toml, a selected profile, then trusted project.codex/config.toml. - The bot config, as app-server request overrides. First-class fields and
broker.codex.configsit at the same level: neither outranks the other, so a setting configured in both is rejected rather than resolved. - 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:
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: falseThat 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:
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:
# $CODEX_HOME/config.toml sandbox_mode = "workspace-write" approval_policy = "on-request" [sandbox_workspace_write] network_access = true writable_roots = ["/srv/shared"]
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 field | Native broker.codex.config key |
|---|---|
broker.codex.model | model |
broker.codex.sandbox | sandbox_mode |
broker.codex.approvalPolicy | approval_policy |
broker.codex.effort | model_reasoning_effort |
broker.codex.developerInstructions | developer_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:
broker:
codex:
config:
sandbox_workspace_write:
network_access: true
web_search: liveSee 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:
logger:
level: info
runLog:
enabled: trueEach 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:
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'senv_key; Codex reads it from the container environment, so it never enters the configCODEX_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.