Skip to content

codex-dynamic — Callback-ready Codex

codex-dynamic starts a normal Codex TUI that can receive dynamic-workflow completion callbacks while you keep chatting. It replaces the manual two-terminal app-server setup with one command.

main Codex session ──launch──> detached workflow
│ │
├─ keep chatting └─ finishes
└<──── completion notification ─────┘
Terminal window
codex-dynamic

The command quietly ensures that the right generation of Codex’s local app server is running, then replaces itself with codex --remote connected to that generation’s Unix socket. Because it uses process replacement, Codex keeps direct control of the terminal, keyboard, signals, window resizing, and exit status.

The launcher is part of the Python CLI package, not the Codex plugin:

Terminal window
uv tool install claude-code-tools

If the package is already installed, refresh it when this command is released:

Terminal window
uv tool install --force claude-code-tools

The helper currently uses Codex’s local Unix-socket transport and therefore supports macOS and Linux. It requires an authenticated Codex CLI 0.136.0 or newer. Version 0.136.0 is the first compatible CLI release combining the WebSocket-over-Unix protocol with the echoed client message IDs used to confirm callback delivery. Codex still marks the app-server and remote TUI interfaces as experimental, so their CLI and protocol surfaces may evolve. The helper rejects an older or unparseable version before starting the server or TUI. Callback preflight also validates the connected App Server, including one that was started outside the helper.

  1. Start a new callback-ready session

    Terminal window
    codex-dynamic
  2. Or resume an existing session

    Terminal window
    codex-dynamic resume --last

    Omit --last to use Codex’s session picker. Interactive TUI arguments are forwarded unchanged. Known non-TUI subcommands such as completion, doctor, and mcp bypass the shared server and run as ordinary Codex commands.

  3. Request a background callback

    $dynamic-workflow Run this workflow in the background. Let me keep using
    this thread, and notify me here when it finishes.

Every workflow run or resume needs host execution for that exact reviewed supervisor command. This lets the supervisor write durable state and launch headless workers without inheriting the outer tool sandbox. For a callback, the same authorization lets its notifier reach the host Unix socket. The workflow remains in the runner’s restricted VM, and headless Codex workers retain their declared sandboxes. A sandboxed launch fails before creating a run; approve only the exact command, never a generic node prefix. Worker danger-full-access is neither needed nor an acceptable workaround.

The main session remains interactive throughout the run. The local app server also stays alive after the TUI exits, so a detached workflow does not depend on the TUI process that launched it. Passive supervision and the local server add no model calls. Completion reporting starts a new turn only while the thread is idle; otherwise it steers into and extends the active turn. Either reporting path invokes the model and consumes tokens.

codex-dynamic marks its tool environment as callback-ready. It supplies the marker both to the TUI process and through Codex’s shell-environment policy, so the default survives restrictive settings such as inherit = "core". The workflow runner therefore arms a callback for detached runs even if the agent forgets to add --notify-current-thread. Pass --no-notify-current-thread only when you intentionally want to disable this default.

The skill still puts the explicit --notify-current-thread flag on every callback-required launch. That turns callback preflight into a hard condition: the runner refuses to create the run when the current thread is not loaded on the selected app server. It never silently substitutes a polling loop.

The runner response remains authoritative: no monitoring is needed only when it contains an armed notification. An ordinary detached workflow without an armed callback still needs bounded status polling or a wait command if you want Codex to track it to completion.

The Python package also installs codex-workflows, a strictly observational dashboard for durable runs. Its default view contains only active workflows; watch refreshes that view about once per second, and show expands one run’s callback, error, and agent-step details.

The command reads one global store for every project. It does not limit results to the current directory. List and watch views identify each run with a Project value derived from the final component of its persisted launch directory. show displays both that label and the complete working directory. The default store is ~/.codex/workflows; set CODEX_WORKFLOW_HOME to inspect another store.

Terminal window
codex-workflows
codex-workflows watch --limit 20
codex-workflows show RUN_ID
codex-workflows show RUN_ID --full
codex-workflows --all

codex-workflows without a subcommand prints a static list. It supports these options:

  • --status STATUS selects one effective status. Repeat it to select several statuses. An explicit status selection replaces the active-only default.
  • --all includes terminal and diagnostic history. It cannot be combined with --status.
  • --limit N returns at most N rows. The default is 50; the accepted range is 1 through 1,000.
  • --json emits the versioned list schema described below, including the full launch directory for every run.
  • --no-color disables ANSI color. The NO_COLOR environment variable has the same effect.

codex-workflows watch displays a full-screen live view until Ctrl-C. It accepts --status, --all, and --limit with the same meanings as the static list. Use --refresh SECONDS to set an interval from 0.2 through 60 seconds; the default is one second. watch requires an interactive terminal and does not support JSON output.

codex-workflows show RUN_ID displays one run from the global store. RUN_ID may be a full ID or an unambiguous abbreviation printed by the list. Pass --full to disable human-output truncation, or --json for complete structured output. Human output includes the project label and full launch directory. Put the global --no-color option before any subcommand:

Terminal window
codex-workflows --no-color show RUN_ID

Every command accepts --help.

By default, human show output truncates field values to the terminal width and limits displayed steps according to that width. Use show RUN_ID --full for complete human-readable details or show RUN_ID --json for complete normalized output.

The active-only default includes starting, running, pausing, paused, canceling, and unverifiable. An unverifiable run is still nonterminal, but the monitor could not strongly certify its supervisor process identity. Use --all for terminal and diagnostic history, repeat --status for a specific selection, or use JSON for scripts:

Terminal window
codex-workflows --status failed --status stale --limit 50
codex-workflows --json
codex-workflows show RUN_ID --json

Workflow status and callback status answer different questions. The workflow status says whether the work is running, completed, failed, or canceled. The callback status says what happened to its completion notification.

The callback states are:

  • armed: the callback target passed preflight and awaits a terminal result.
  • sending: the bounded notifier is delivering or confirming the message.
  • delivered: the app server confirmed that the Codex chat received it.
  • failed: delivery stopped without an ambiguous accepted submission.
  • unknown: delivery may have occurred, but the notifier could not confirm it safely without risking a duplicate message.
  • invalid: the dashboard could not validate the callback metadata.

Thus, delivered is not a workflow result. A successfully delivered failure has workflow status failed and callback status delivered. Terminal workflows remain hidden from the default active view regardless of callback status.

List-mode JSON uses this version-1 envelope:

{
schemaVersion: 1,
complete: boolean,
limit: integer,
observationComplete: boolean,
observationSkipped: integer,
truncated: boolean,
runs: Run[]
}

The runs array contains at most limit records. truncated is true only when the row limit omitted matching records. observationComplete is false when the shared process-probe deadline prevented classification of one or more runs. observationSkipped counts skipped process observations. A cached observation can affect several runs. complete is true only when neither condition made the result incomplete. Pass a larger --limit, up to 1,000, when truncation is the only issue. Human-readable output warns when incomplete process observation may have omitted a match.

Each list item has the following shape. A null type marks a field that can be unavailable, invalid, or absent from durable state.

Run = {
schemaVersion: 1,
runId: string,
abbreviatedRunId: string,
workflowName: string,
workflowPath: string | null,
cwd: string | null,
recordedStatus: string,
status: string,
agentProgress: {
total: integer,
completed: integer,
failed: integer,
canceled: integer,
running: integer
},
activeWorkers: integer,
createdAt: string | null,
startedAt: string | null,
completedAt: string | null,
updatedAt: string | null,
durationSeconds: number | null,
callback: Callback | null,
activity: string,
error: string | null,
stateError: string | null,
callbackError: string | null
}

Callback metadata has one stable shape, including invalid metadata. An invalid callback has status: "invalid", its diagnostic in error, and null for fields that could not be read.

Callback = {
status: string,
attempts: integer | null,
createdAt: string | null,
updatedAt: string | null,
deliveredAt: string | null,
deadlineAt: string | null,
lastAttemptAt: string | null,
terminalCompletedAt: string | null,
terminalStatus: string | null,
clientUserMessageId: string | null,
endpoint: string | null,
threadId: string | null,
timeoutMs: integer | null,
turnId: string | null,
notifierPid: integer | null,
notifierStartedAt: string | null,
notifierProcessStatus: string | null,
error: string | null
}

show RUN_ID --json returns one Run directly and adds a steps field. List items omit steps, even when steps exist.

steps: Array<{
id: string,
label: string,
status: string,
attempt: integer | null,
startedAt: string | null,
completedAt: string | null,
durationSeconds: number | null,
workerPid: integer | null,
threadId: string | null,
error: string | null
}>

Within schema version 1, field names, meanings, types, and nullability remain stable. Consumers should ignore unknown fields so compatible additions do not break them. An incompatible contract change increments schemaVersion.

The command reads version-1 JSON under $CODEX_WORKFLOW_HOME/runs/*/state.json and its optional completion-notification.json files. CODEX_WORKFLOW_HOME defaults to ~/.codex/workflows. It does not invoke a model, connect to or require the app server, write workflow state, send signals, or maintain another database. Consequently, listing and watching use zero tokens.

For a nonterminal run, the dashboard compares the recorded supervisor PID and process-start identity with the live process. A missing supervisor is shown as orphaned, and a reused PID is shown as stale; the durable status remains unchanged. Malformed or partial files and corrupt callback metadata remain visible as diagnostics instead of hiding otherwise readable runs. Set NO_COLOR=1 or pass --no-color before the subcommand for plain output.

An app server loads its plugin catalog once, and Codex currently has no in-session plugin-reload command. codex-dynamic handles this automatically by using versioned server generations instead of replacing the shared server.

  1. Install or upgrade the plugin

    Complete the marketplace or plugin update normally.

  2. Start or resume Codex

    Terminal window
    codex-dynamic
    # Or restore a saved conversation:
    codex-dynamic resume --last

The launcher fingerprints the selected Codex executable, plugin catalog, and server-affecting options. If they match an existing healthy generation, it reuses that generation. Otherwise it starts and certifies a new socket before making it the default. Existing TUIs and in-flight callbacks remain connected to the old socket, so they are not interrupted and keep the plugin snapshot with which they started.

Codex does not expose a reliable count of clients attached to an app server, so the helper never guesses that an old generation is unused and kills it. Retained generations are capped at 64; reaching that defensive limit produces an explicit cleanup instruction instead of disconnecting sessions silently.

Most users never need codex-server start; codex-dynamic starts the server automatically. These commands are useful for inspection and cleanup:

CommandPurpose
codex-server statusShow the current default generation
codex-server logsShow the current generation’s recent output
codex-server logs --followFollow the current generation’s output
codex-server restart --forceStop all generations, then start a fresh one
codex-server stop --forceStop every retained generation

Add --json to lifecycle commands for machine-readable output.

Without --force, stop and restart refuse to terminate a running generation. The guard is deliberately conservative because a generation can have more than one TUI attached and Codex exposes no graceful reconnect protocol.

The helper reuses only a healthy app server that it previously started and can certify against its recorded ownership and plugin state. It rejects an app server started another way because it cannot verify which plugin snapshot that server loaded. Lifecycle commands also verify the recorded process identity before sending a signal.

Per-generation state and logs live under $CODEX_HOME/cctools-app-server/<generation>/. The default CODEX_HOME is ~/.codex. Set CCTOOLS_CODEX_BIN when the desired Codex executable is not the first codex on PATH.

You can use Codex’s commands directly instead of installing this helper. Keep the server running in one terminal and start the TUI in another:

Terminal window
# Terminal 1
codex app-server --listen unix://
# Terminal 2
codex --remote unix://

To resume a conversation manually, use codex resume --remote unix:// and select it in the picker or pass its session ID.

codex-dynamic combines two independent pieces: a small Python lifecycle helper for Codex’s local app server, and the plugin’s durable JavaScript workflow runner. They meet at the app server’s local Unix socket. There is no MCP layer, extra API key, or hosted coordination service.

codex-dynamic
|-- select generation --> [versioned supervisor] --> [versioned app server]
`-- exec ---------> [Codex TUI]
|
`-- generation socket --> [versioned app server]
[Codex TUI] -- reviewed launch --> [workflow supervisor]
|
[restricted JS engine]
|
[codex exec workers]
| terminal result
v
[bounded notifier]
| callback RPC
v
[local app server]
| start or steer turn
v
[originating thread]
  1. Establish a callback-capable session

    codex-dynamic resolves the exact Codex executable, checks its version, and fingerprints that executable together with the effective plugin catalog and server options. It reuses the matching healthy generation or starts a supervised app server on a generation-specific Unix socket. The current generation marker changes only after the new listener passes ownership, process-identity, plugin-snapshot, and socket-peer checks. A failed rollout therefore leaves the previous default untouched.

    The launcher then replaces itself with codex --remote using that exact socket. It is not a proxy in front of the TUI, so Codex still owns the terminal, signals, and exit status directly. The same immutable endpoint is injected through Codex’s shell-environment policy, making callbacks the default for detached workflow launches even under a restrictive inheritance policy.

  2. Verify the callback before creating the run

    The workflow command receives CODEX_THREAD_ID from Codex’s tool shell and asks the app server whether that exact thread is loaded. Only after this preflight succeeds does it create durable run state and record the callback as armed. A mismatched server or ordinary non-remote TUI therefore fails before any workflow starts.

  3. Run independently of the conversation

    Before detaching, the launcher stores an immutable copy of its bundled runner inside the run directory. The supervisor, restricted JavaScript engine, and eventual notifier all launch from that copy, so replacing a plugin-cache version cannot strand an in-flight run.

    The detached supervisor executes the reviewed JavaScript in a restricted VM and launches each reasoning step as a separate headless codex exec worker. Durable checkpoints, concurrency limits, launch caps, deadlines, worker timeouts, and process-tree cleanup remain active even while the main TUI handles other turns.

  4. Deliver into the originating thread

    When the run becomes terminal, a separate bounded notifier connects to the same Unix socket and resumes the original thread. It sends turn/start when the thread is idle or turn/steer when a turn is already active. The latter is what lets a completion join the conversation without discarding the user’s current request.

    A stable client message ID and thread-history reconciliation confirm delivery without blindly duplicating a message after an ambiguous network failure. Retry limits belong to the notification, and callback failure never changes a successful workflow result into a failed run.

    The callback envelope has a hard 4 KiB budget. Large results or errors are represented by short, UTF-8-safe previews, while the notification links to the durable state file containing the complete value. Large reviewer reports therefore do not consume the main conversation’s context.

The workflow and its callback share a durable run directory, but they have separate outcomes. The normal callback path is:

preflight -> armed -> workflow terminal -> sending -> delivered
|-> failed
`-> unknown

The workflow result becomes durable before notification begins. A notifier failure therefore cannot change a completed workflow into a failed workflow. The dashboard can still show the complete result and callback diagnostic.

Delivery identity belongs to one terminal generation of a run. The runner binds a stable client message ID to the terminal fingerprint. When a resumed run reaches a new terminal generation, it receives a new fingerprint and the callback is armed again. A notification delivered for an earlier failed attempt therefore does not silence the resumed attempt.

The notifier retries transient connection and server errors within fixed time and submission limits. If a connection fails after a submission may have been accepted, it reconciles the stable client message ID against thread history before sending again. This produces delivered when confirmation is found and unknown when safe confirmation is impossible.

There are two supervisors with different lifetimes:

  • The app-server supervisor keeps the local callback endpoint alive beyond the TUI process. During startup, it publishes both supervisor and worker identities before releasing the worker to become the app server. Lifecycle commands verify recorded process start identities before sending signals and never stop a compatible server they do not own.
  • The workflow supervisor owns one durable run and its worker process groups. It contains runaway workflow JavaScript, terminates orphaned worker trees, and preserves nonterminal cleanup state when shutdown cannot be confirmed safely.

This separation means the TUI, workflow, callback notifier, and app server can fail independently. The durable run result remains inspectable even if callback delivery is temporarily unavailable. Active runs also remain independent of plugin installation state because each one owns a verified runner snapshot.