Skip to main content

Codex SDK agent

@flow-state-dev/codex — run OpenAI's Codex agent as a block. You hand it a prompt, it works in a directory you point it at, and it hands back a handle describing the run. What it says, what it runs, and what it changes show up in your flow's item stream as it goes.

This is one of two harnesses flow-state-dev ships. Coding agents covers what they have in common — the handle they both return, and the configuration contract underneath cwd, resume and onSession. This page is the Codex half.

When to use it​

Reach for this when you want coding work done by Codex specifically, or when you want a choice. A step that reads a handle rather than a vendor's own result can run either harness, so switching is a configuration change rather than a rewrite.

Codex owns its own loop. flow-state-dev does not drive it step by step or re-implement its tools. It runs the Codex SDK, which spawns the Codex CLI in a subprocess, watches the stream, and turns each thing that happens into a canonical item.

Installation​

The SDK is an optional peer dependency, pinned to an exact version:

pnpm add @flow-state-dev/codex @openai/[email protected]

Codex's JSONL output sits behind the CLI's --experimental-json flag and can change in a lockstep CLI and SDK release, so building a block against any other installed version fails immediately, naming both versions. There is no option to override it. The way to take a newer Codex is a release of @flow-state-dev/codex that has been tested against it.

Building also fails if an SDK is installed but its version cannot be read, which can happen under Yarn PnP or a custom loader.

The SDK brings the codex binary with it, so there is no separate CLI to install. At runtime it needs either CODEX_API_KEY in the environment or a logged-in Codex account. It also expects to work inside a git repository; pass skipGitRepoCheck if you mean not to.

Quick start​

A capability is a reusable bundle you attach to a block. Installing this one lets a generator hand work to Codex as a tool:

import { generator } from "@flow-state-dev/core";
import { createCodexAgentCapability } from "@flow-state-dev/codex";

const planner = generator({
name: "planner",
// The generator's own model, as a flow-state-dev model id.
model: "openai/gpt-6-luna",
// Codex's model is set separately, in its own ids — see the next example.
uses: [createCodexAgentCapability({ cwd: async () => checkoutForThisRun() })],
});

As a sequencer step​

import { sequencer } from "@flow-state-dev/core";
import { codexAgent } from "@flow-state-dev/codex";

const pipeline = sequencer({ name: "do-the-work" }).step(
codexAgent({
cwd: (ctx) => workspacePathFor(ctx),
resume: (ctx) => previousSessionIdFor(ctx),
onSession: (id, ctx) => rememberSessionId(id, ctx),
// Options passed straight to Codex, in Codex's own vocabulary — `model`
// here is a Codex model id, not a flow-state-dev one.
thread: { model: "gpt-5.4-codex", sandboxMode: "workspace-write", approvalPolicy: "never" },
}),
{ abortSignal: () => AbortSignal.timeout(runTimeoutMs) },
);

The block's input is the prompt, and nothing else. Everything that decides where a run writes or which conversation it continues is configuration. The same block can be handed to a model as a tool, and a field on the input is a field the model could set.

What it emits​

Codex outputflow-state-dev item
Agent messagemessage
Reasoning summaryreasoning
Shell command and its resulttool_output, opened when the command starts and settled with its exit code
File changetool_output naming each path and whether it was added, updated or deleted
MCP tool calltool_output, named mcp:<server>/<tool>
Web search, to-do listtool_output
Thread and turn lifecycletransient status
Errorserror

Every item carries provenance, so the devtool shows the full trace of what the run did.

In the version this package supports, Codex doesn't send a to-do list, and a Codex block records no run's plan. Show its plan as not reported rather than empty.

Output this package doesn't recognize becomes a status note rather than an error. The format is experimental, so unknown output doesn't fail the run.

Where the run works​

cwd is a function you write. It is called once per run, before anything is spawned, and its answer becomes the directory Codex works in. It is handed the block context and never the prompt, for the reason Coding agents gives.

It is a working directory, not a fence: the run can still address paths outside it. sandboxMode on thread is the fence.

Deriving a safe directory per run is a recipe rather than a one-liner, and the Claude Code page works one through in Reusing a directory across runs. It applies here unchanged. If your work arrives as rows on a task board, the harness manager derives and provisions the checkout for you.

Environment variables​

Leave client.env unset and the Codex CLI process starts with your server's entire process.env: every API key, token and connection string in it, readable by any shell command the model runs. Setting client.env replaces that environment. It does not add to it.

To hand the run only what it needs, build client.env with harnessEnv from @flow-state-dev/core:

import { harnessEnv } from "@flow-state-dev/core";
import { codexAgent } from "@flow-state-dev/codex";

codexAgent({
cwd: (ctx) => workspacePathFor(ctx),
client: { env: harnessEnv({ pass: ["PATH", "HOME", "CODEX_API_KEY"] }) },
});

Only the named variables are passed. Nothing is implicit, so name PATH and HOME yourself; the run's shell commands and git are found through PATH. A named variable that isn't set on the server is left out, and one set to an empty string is kept. Values are read once, when harnessEnv is called. Called inline as above, that is when the block is built, so a variable set after that never reaches the run.

To add a fixed value, spread the allowlist and set it beside it: { ...harnessEnv({ pass: [...] }), CI: "1" }. Spreading process.env there hands the run every secret the server holds.

Continuing a thread​

Codex calls a conversation a thread. Continuing one is two halves, and you write both:

codexAgent({
resume: (ctx) => myState(ctx).codexThreadId, // read: null starts fresh
onSession: (id, ctx) => myState(ctx).save(id), // write: called mid-run
});

This package keeps no session state of its own. resume reads the thread id from wherever you keep it, and onSession is called with the id the moment the run names it, which is before the run does any work — early enough that a run which is later cancelled is still resumable. Coding agents covers why the hook exists alongside the handle's own sessionId.

onSession fires only when Codex actually names a thread. If you resume an id Codex no longer has, it names none, the run fails, and the id you already hold is left exactly as it was rather than being overwritten with a dead one. harnessFailureKind(error) from @flow-state-dev/core returns "unknown-conversation" for that failure, and null for any other, including a sign-in error before Codex names a thread.

Keeping no session state also means the block declares none, which is what lets a task board dispatch it without any further option. The Claude Code harness needs detached: true for the same effect.

Cancelling​

Nothing to write. The block forwards its own signal into the turn, and a fired deadline throws rather than returning a handle. The thread id has already reached onSession by then, so the run can be continued.

The deadline bounds Codex, not what Codex ran. A command Codex started can outlive the process being killed. The sandbox setting is what bounds that class of thing.

Usage and cost​

A completed turn reports token usage. The handle carries the two counts every harness reports, plus Codex's full breakdown — cached input, cache writes and reasoning output — under codexUsage.

Cost is an estimate, derived from flow-state-dev's model price table, and cost.basis always reads "estimated". It is absent rather than zero when:

  • No model was configured, so the run used Codex's default. Nothing in the output names the model that ran, so there is nothing to price.
  • The price table has no entry for the model. One patch release teaches it.
  • The turn never completed, so there is no usage.

Errors​

Every one of these is fatal for the run that raised it:

SituationWhat happens
Empty promptThrows before anything is spawned
SDK not installedThrows on the first run, with an install hint
A different SDK version installed, or one whose version cannot be readThrows when the block is built
A working directory or a signal in thread or clientThrows when the block is built
The CLI exits non-zeroThrows, carrying its stderr
Codex doesn't have the thread resume namedThrows a CodexAgentRunError whose harnessFailure is "unknown-conversation"
The deadline firesThrows

A turn the model itself fails is not in that list. It comes back as a handle with status: "errored" and outcome: "failed", and Codex's message on failureMessage. Nothing here retries.

Limitations​

  • One turn per call. The block runs a turn and returns.
  • No images and no structured output.
  • No approval prompts. A headless run is configured not to ask, so the sandbox and approval policy you set are the whole of the permission story.
  • With client.env unset, the run can read every variable in your server's environment. See Environment variables.