Codex Engine¶
CodexEngine is a standard LazyBridge Engine
that runs the model/tool loop through the locally installed Codex CLI
instead of a raw provider API call. It is the OpenAI-side counterpart of
ClaudeCodeEngine: the application stays a normal
lazybridge.Agent, and Codex is only the engine that drives it.
Scope and design¶
The integration uses an authenticated ChatGPT/Codex account without putting an API key in the project. It starts no persistent service, opens no port, and copies no credential.
LazyBridge keeps owning conversation memory and tools. For one agent run the
engine starts one ephemeral, read-only, approval-free thread and exposes
the current LazyBridge tool list to it as App Server dynamic tools; every
call comes straight back to Tool.run().
Durable threads¶
persist_thread=True makes the thread outlive the subprocess, and
engine.thread_id is then the handle another process can pick up:
engine = CodexEngine(cwd=repo, persist_thread=True)
Agent(engine, name="reviewer")("review src/parser.py")
handle = engine.thread_id # store it
# ...later, another process entirely:
Agent(CodexEngine(cwd=repo, thread_id=handle), name="reviewer")("and the retry path?")
The follow-up does not re-read what the first turn already read: Codex' own transcript carries it. That moves the conversation's home, so the engine also:
- stops prepending
Memoryon a resumed thread (Codex has the history; sending it again gives the model two chronologies of the same conversation).Memorykeeps recording for the application's own audit/recovery use; - refuses to retry a turn lost after the request was sent, raising
CodexTurnUncertaininstead —turn/startis not idempotent, and the server can accept a turn and then drop the connection before answering, so the window opens at send, not at acknowledgement. Resume the thread and inspect it before deciding; - serialises runs per thread id inside the process. A thread is one transcript: two turns appended at once interleave.
Durable threads are stored by the Codex CLI itself (they show up in its session history), and both processes must share the same Codex home and account.
Setup¶
The engine does not require codex to be on PATH: it resolves CODEX_BIN
first, then PATH, then the Codex desktop app's versioned install directory
(%LOCALAPPDATA%/OpenAI/Codex/bin/<hash>/codex.exe), which the installer does
not add to PATH.
No Python extra is needed — unlike lazybridge[claude-code], this engine has
no SDK dependency, only the CLI.
Usage¶
from lazybridge import Agent, CodexEngine
agent = Agent(
name="research",
engine=CodexEngine(system="Be concise."),
tools=[search, specialist_agent],
)
print(agent("Summarise the latest filing").text())
CodexEngine composes like any other engine — tools=[...] with a child
agent, Agent.chain, AgentPool, Agent.stream, output=<model> — because
those all dispatch through Engine.run()/Engine.stream() and never
special-case the engine type.
Constructor options mirror LLMEngine/ClaudeCodeEngine where the underlying
runtime supports them: model, cwd, system, reasoning_effort,
request_timeout, stream_idle_timeout, max_retries, retry_delay,
tool_timeout. The system value is sent as an App Server developer
instruction, separately from the user task, so it retains instruction priority.
Why not codex exec¶
codex exec --json was the obvious candidate and was rejected: it cancels
non-interactive MCP tool calls unless --dangerously-bypass-approvals-and-sandbox
is passed, and that flag also removes the read-only sandbox. CodexEngine
therefore talks to codex app-server over JSON-RPC directly and never uses
that bypass.
Protocol notes¶
Verified against a live codex app-server (codex-cli 0.148.0); the
authoritative schema comes from codex app-server generate-json-schema --out <dir>.
thread/starttakessandbox: "read-only"— the kebab-caseSandboxModeenum."readOnly"is rejected outright withunknown variant.dynamicToolsonthread/startworks but is absent from the generated schema, so it is the field most likely to break on a Codex upgrade. The test fixture asserts the exact request shape so a regression fails locally.- Token usage arrives only through
thread/tokenUsage/updatednotifications;turn/completedcarries none.totalis cumulative over the thread, not the turn (measured: 15137 after turn 1, 30292 after turn 2), so on a resumed thread the engine subtracts the total reported before the turn began.lastis not used instead because it is only the final model call and would drop the ones made before each tool round-trip. - Every notification carries
threadId/turnId, andturn/startreturns the turn's id. The engine attributes usage by turn id and ignores aturn/completednaming a different turn — on a resumed thread the server can replay older ones, and taking the first would return a previous answer. thread/resumerestores a durable thread in a different process (verified live), and acceptsdynamicToolseven though the generated schema omits the field there too — a resumed thread must re-register them, since the callbacks live in the new subprocess.ephemeral: truemeans unresumable, not merely short-lived:thread/resumeon such a thread answersno rollout found for thread id.cost_usdis always0.0. Under ChatGPT-plan auth the App Server reports plan rate-limit percentages, never a per-turn price. Token counts are populated, soSession.usage_summary()still attributes tokens per agent.- The App Server numbers its own requests to the client from 0, independently
of the client's ids, so the read loop dispatches on the presence of
methodrather than on the id.
Multimodal¶
images= is forwarded: a URL passes through as an image UserInput item, and
inline bytes are sent as a data: URL.
audio= is not forwarded. The protocol has audio/localAudio variants
and accepts them without error, but the model then reports it cannot access
the attachment (verified live with both), so the engine drops it with a
UserWarning instead of paying for an ignored attachment.
Codex can also produce images — the protocol's ThreadItem union includes an
imageGeneration item with a savedPath. This engine reads only the final
agentMessage text, so a generated image would not reach the Envelope.
Generated audio exists only in the realtime session API, not in this
turn-based path.
Structured output¶
output=<model> is enforced server-side via turn/start's native
outputSchema whenever the schema qualifies — the same guarantee
ClaudeCodeEngine gets from the Agent SDK's output_format. turn/start
only accepts OpenAI-strict schemas: additionalProperties: false on every
object and required listing every property, which a plain Pydantic
schema doesn't satisfy on its own (a bare schema fails the turn with
invalid_json_schema). lazybridge.core.structured.to_openai_strict_schema
does that rewrite: an optional property is carried into required only when
it already accepts null as written (an Optional[X] = None field, a
Literal[..., None]), since that's the only case where forcing it into
required doesn't change what a round-trip through the model then accepts.
Not every schema qualifies. The engine falls back to the old prompt-priming
behavior — ask for the shape in the prompt, let
Agent._validate_and_retry's post-hoc repair catch drift — whenever the
native rewrite would have to change accepted semantics instead of just
restating them:
- an optional property whose own type never admits
null(e.g.count: int = 5— forcing it intorequiredwould let the model legally answernullthere, which the destination model then rejects); - a model with
extra="allow"(or any object whoseadditionalPropertiesis explicitlytrue/typed) — closing it would forbid dynamic fields the destination type actually accepts; - a fixed-length tuple (
tuple[str, int], rendered asprefixItems) — a keyword outside the strict-mode subset with no equivalent to translate it into; - the output type's top level isn't an object (e.g.
output=list[str]).
output=str (the default) and output=Any are unaffected either way — there
is nothing to constrain.
Distinguishing LazyBridge threads on disk¶
Every thread a CodexEngine creates is tagged with the App Server's own
threadSource field (ThreadStartParams.threadSource — verified against the
generated protocol schema, and observed on disk as
session_meta.payload.source in a real rollout file under
~/.codex/sessions/..., alongside values like "vscode" the interactive CLI
sets for its own sessions):
engine = CodexEngine() # thread_source="lazybridge" (default)
engine = CodexEngine(thread_source="my-app") # a caller-specific label
engine = CodexEngine(thread_source=None) # omit it entirely
This is creation-time metadata only — sent on thread/start, never
re-sent on thread/resume — because the protocol has no endpoint to change
it after a thread exists. It has no bearing on codex resume's picker
(which titles sessions from their content, not this field); it exists so a
script can tell LazyBridge-created threads apart from interactive ones by
grepping rollout files for "source": "lazybridge", e.g. as a starting point
for a retention/cleanup pass.
Not implemented yet¶
- No model validation.
model=is passed straight tothread/startwithout checking it against the account'smodel/list, so an invalid model surfaces as whatever error the App Server returns. - No cross-process locking.
persist_thread=Trueserialises runs against one thread id within a process; two processes resuming the same thread at once is still on the caller to prevent. - No built-in file/web tools. Claude's
Read/Glob/Grep/WebSearchsurface has no exposed counterpart here; Codex runs with its own read-only sandbox rooted atcwd.