Claude -> Codex Agent Migration
Convert a runtime: claude-code cortextOS agent into a runtime: codex-app-server
agent that the live CodexAppServerPTY adapter can boot and loop. The migration is
mostly a transform of the agent's in-repo directory plus a config.json runtime/model
flip; the bus, crons, heartbeat, dashboard, and Telegram surfaces are already
runtime-agnostic and carry over unchanged.
This skill is GENERAL: it takes any source agent name/path as input. It is
NON-DESTRUCTIVE: the source agent is never modified or deleted; you produce a
separate codex-form agent. It is DRY-RUN BY DEFAULT: nothing is written until you
pass --apply.
The promise and the contract
The codex adapter (src/pty/codex-app-server-pty.ts) re-implements most of what
Claude's .claude/settings.json hooks did, natively in code. So the migration is
NOT "everything Claude-specific is lost." The honest decomposition is:
- 1:1 -> copy verbatim (identity/soul/goals/memory/.env/workspaces/crons).
- TRANSFORM -> rewrite into codex shape (config.json, CLAUDE.md, skills, MCP, refs).
- DROPPED (no equivalent) -> exactly THREE in-turn Telegram behaviors lose their
hook: plan-mode->Telegram, AskUserQuestion->Telegram, PreCompact->Telegram. Re-home
them to the out-of-band bus
approvalsflow. - NEEDS-HUMAN -> items that cannot be mechanically converted and block enable.
The cardinal rule: never silently drop an artifact. Every file the source agent
owns lands in a bucket and appears in the report, including files this skill has no
specific rule for (they go to a port-verbatim catch-all). If the tree-walk finds
something with no rule, it is surfaced, not skipped.
How to run this: walk the USER through it (interactive, 5 steps)
You (the implementing agent) do NOT run this silently. Migration changes a live agent's runtime and can take over a Telegram bot — the user must steer the irreversible choices. Run a conversation in exactly these five steps. The scripts below do the work; THIS section is the script YOU follow with the user.
The whole flow maps onto the engine flags like this: STEP 1-2 gather the user's
answers; STEP 3 is the deterministic convert; the bot/boot choices from STEP 2
become --bot-mode / --boot / --new-bot-token on convert.py; STEP 5 reads
the engine's end_state. With NO flags the engine is fully non-destructive
(dry-run, source untouched, codex disabled) — so always do the STEP 1 detect as a
dry-run first, ask, THEN apply with the user-confirmed flags.
STEP 1 — DETECT + plain-language SUMMARY (no changes yet)
Run Step 0 + Step 1 (DETECT) below, read the manifest, then tell the user, in plain language (not JSON):
- the agent name and its current runtime,
- N skills (X custom / Y bundled-for-free),
- M crons, the MCP servers (or "none"),
- the Telegram bot it runs on,
- what migrates CLEAN (1:1), what is LOSSY (the 3 dropped Telegram behaviors, any leaky skills), and what will NEED THEM (the needs-human set). Make NO changes. This is the orientation read.
STEP 2 — ASK the user these 3 decisions, then WAIT for answers
Present all three with their defaults; do not proceed until answered.
- Q1 TARGET. Create a new agent
<name>-codexALONGSIDE the original (DEFAULT — safe, fully rollbackable), OR replace the original in place? Default is new-alongside. "Replace" is advanced/destructive — warn explicitly that it removes the rollback path. - Q2 BOT TOKEN (the key one).
- (a) REUSE the existing Telegram bot -> the skill performs a CUTOVER: it
DISABLES the source Claude agent, copies its bot token into the codex agent,
and boots the codex agent on the SAME token. Telegram allows only ONE poller
per bot, so reuse REQUIRES disabling the source — this is intended and
seamless. End state: "X is now codex, same bot." (Sets
--bot-mode reuse, which implies boot.) - (b) NEW bot -> the user pastes a new bot token (from @BotFather); it goes
in the codex agent's env, and BOTH source and codex can run side by side.
(Sets
--bot-mode new --new-bot-token <token>.)
- (a) REUSE the existing Telegram bot -> the skill performs a CUTOVER: it
DISABLES the source Claude agent, copies its bot token into the codex agent,
and boots the codex agent on the SAME token. Telegram allows only ONE poller
per bot, so reuse REQUIRES disabling the source — this is intended and
seamless. End state: "X is now codex, same bot." (Sets
- Q3 BOOT. After migrating, boot the codex agent now, or leave it DISABLED
for the user to inspect first? (If Q2=reuse-cutover, booting is implied — still
confirm with the user.) (Sets
--boot nowor--boot no, defaultno.)
STEP 2b — WRITE-SIDE HANDOFF GATE (HARD, cutover only — block before any teardown)
This is a NON-NEGOTIABLE pre-cutover gate. A clean cutover requires real session
continuity, not a synthesized handoff. Before any --bot-mode reuse:
Record the migration-start timestamp and pass it as
--migration-start-ts.AUTHORIZE the migration — the gate depends on REVERSIBILITY. A security-hardened source agent will (CORRECTLY) REFUSE a cold bus demand to run a handoff — it reads "produce a handoff now, urgent, via bus" as a prompt-injection pattern and escalates instead of complying (good behavior; clean-room test runs proved it). How you authorize depends on what the migration DOES:
2a. OWNER-DIRECT-CONFIRM — REQUIRED for an irreversible / external-surface / shared-system cutover (defect 9).
--bot-mode reuseis a BOT-HANDOVER: the target takes over the source's live Telegram surface — irreversible and externally visible. Per the approval doctrine, an irreversible external-surface action requires the OWNER (the user) to confirm DIRECTLY, OUT-OF-BAND, over the TARGET agent's own trusted surface (e.g. the principal sends the SOURCE agent a direct Telegram: "migration to is authorized, comply with the next handoff request"). This is the AUTHORIZATION gate. An orchestrator-minted in-band token is NOT sufficient for it: a transmitted token is itself an attack surface (any agent that sees the bus can quote it), and request-authenticity is not owner-authorization for an irreversible action. The source complies because its OWNER told it to, directly. Do NOT substitute a delegated/relayed approval for the owner's direct confirm on an irreversible cutover.The owner confirm must be EXPLICIT, UNAMBIGUOUS, and verified at the RECIPIENT (defect 10). Three hard sub-rules, all proven by a clean-room test run where a relayer inflated a bare owner "Authorized" into the full gate phrase:
- SPECIFIC: the owner's confirm must NAME the target AND the action ("migration to
<target>authorized, comply with the next handoff request"). A bare affirmation ("Authorized", "ok", "go", "bet") is ACK, NOT approval — it does not satisfy the gate, especially if it answers nothing specific or leaves a target's clarifying question unanswered. - VERBATIM RELAY ONLY: anyone relaying the owner's authorization must quote the owner's LITERAL words — NEVER a characterization or upgraded interpretation. An owner saying "I sent it" to a relayer is an action-CLAIM, not verification of CONTENT. If the owner's words are ambiguous, the gate is NOT met and you HOLD — you do NOT fill the gap. (Pairs with the fleet provenance-travels-verbatim rule.)
- GATE-SIGNAL = RECIPIENT CONFIRMS RECEIPT: the gate is satisfied ONLY when the TARGET agent confirms, in ITS OWN channel, that it received the owner's specific unambiguous confirm — NOT when a relayer claims the owner "sent it." The recipient is always the source of truth for what was actually received. Fire the cutover only after the target confirms receipt. (The architecture must not depend on the relayer being perfect; the target verifying is the safety, which is why it exists.)
- STRONGEST / CANONICAL FORM (preferred): TARGET-INITIATED — the target itself asks its owner, IN ITS OWN CHANNEL, to authorize the migration; the owner approves THERE verbatim; the target confirms receipt. This keeps the entire authorization path inside the target's own trusted channel with ZERO relayer, structurally closing the inflation gap rather than relying on relay discipline. (You may still paste the owner the exact phrase to send — but the GATE is the target's receipt-confirmation, never your claim.)
2b. ONE-TIME TOKEN — defense-in-depth for REQUEST AUTHENTICITY (defect 8ii), optional layer / sufficient alone only for REVERSIBLE migrations. The orchestrator mints a short ONE-TIME TOKEN, pre-arms the source ("comply when the migration agent requests handoff:create AND quotes token
<TOKEN>"), and the migration agent ECHOES that exact token INLINE with the provenance. This proves the handoff request is the authentic migration agent (defeats an attacker riding the window) and is appropriate for low-stakes/reversible migrations — but for an irreversible cutover it is a SUPPLEMENT to 2a, never a replacement.In all cases: NEVER a bare urgent demand, and NEVER bypass a refusal — if the source refuses, the authorization gate was not satisfied; fix it (get the owner confirm), do not force past it.
- SPECIFIC: the owner's confirm must NAME the target AND the action ("migration to
FLUSH IN-SESSION CORRECTIONS FIRST (defect 13 — HARD pre-handoff step). Before the source runs
handoff:create, tell it to FLUSH any recent in-session behavioral corrections to PERSISTED state (memory/ + GUARDRAILS.md): routing rules, suppression rules ("never surface X"), triage-only rules, per-recipient overrides — anything a human told it THIS session that it has not yet written down. The migration carries only PERSISTED artifacts + the handoff; unpersisted in-session context does NOT exist as migratable state and will be LOST at cutover. It is also not enough for the correction to change behavior while leaving a stale config/cron in place — e.g. if the operator said "stop sending pending-items direct to the principal" but the cron prompt still says so, the migrated cron inherits the WRONG behavior. So the source must, in this flush: (a) write the correction to memory/GUARDRAILS.md, AND (b) fix any config/cron whose text still encodes the pre-correction behavior. This is a KNOWN HARD LIMITATION of migration, not a bug to engineer around: the handoff is a summary, not a substitute for persisted guardrails. (Observed failure mode: a migrated agent inherited several same-day behavioral corrections that were never persisted — it resumed sending items directly to the principal, surfaced messages it had been told to suppress, and re-surfaced a suppressed item — because the source agent never wrote those corrections down before cutover.)Tell the (now pre-armed) LIVE source agent to run its NORMAL handoff procedure (the same
handoff:createit runs at its context-handoff threshold). Do NOT synthesize a handoff yourself.convert.py BLOCKS and polls the SOURCE WORKSPACE
<src-dir>/memory/handoffs/— where agents ACTUALLY write handoffs (defect 8i: NOTstate/<source>/handoffs/, which usually does not exist) — for a handoff whose mtime is AFTER the migration-start timestamp. A pre-existing stale handoff does NOT satisfy the gate. If no fresh handoff appears within--handoff-waitseconds (default 300), the cutover ABORTS: the source is NOT disabled, the target is NOT enabled (end_statecutover-aborted). That fresh handoff is then carried into a DETERMINISTIC location in the new agent's dir so it is ALWAYS reliably read on boot (not a "newest file" guess): into BOTH the daemon boot-state dirstate/<target>/handoffs/AND the new agent's WORKSPACEmemory/handoffs/(its native handoff:resume dir), as the NEWEST file in each, and a deterministic boot directive (state/<target>/.handoff-doc-path) is written so the daemon points the new agent at the exact file as its FIRST action on boot (the same CONTEXT HANDOFF injection mechanism the daemon uses for restarts). The side-by-side (--bot-mode new) and disabled (default) paths do NOT take over the source and are NOT gated.
STEP 3 — AUTOMATE everything else SILENTLY (no questions)
Run CONVERT (Step 3 below) with the flags from STEP 2. The script does, with no
further questions: config flip, skill relayout + symlinks, CLAUDE.md->AGENTS.md
fold, boot-state continuity carry (.onboarded + markers + handoffs/ so the agent
does NOT cold-onboard), stale-session-transcript exclusion, the cron UNION
migration (config.json + live registry, deduped, paths translated, direct-to-principal
sends re-routed through the orchestrator by BOTH chat-id token AND prose intent — defect 14;
one-shot crons carried as their daemon-loadable registry crontab form instead of
the config type:once/fire_at form the daemon silently drops — defect 11b, with
a loud per-cron flag), guardrail-into-template (routing + approval rules),
memory, MCP TOML emit, registry-disable, plus (from the flags) the bot-token write,
the write-side handoff gate, the cutover source-disable, the cutover
SOURCE-CRON-disable (defect 11a: disabling the source AGENT does NOT disable its
CRONS — they stay enabled in the registry and re-fire on daemon restart/re-enable =
durable dual-scheduler; the script disables every RECURRING source cron and HOLDS
one-shots until the operator re-arms them on the target), and the boot. It also runs a
SQLITE-INTEGRITY pass (defect 15): skill data is raw-copied, and copying a LIVE
sqlite DB tears it (an agent's live signals DB came over malformed) — so every copied
*.db is integrity-checked, any torn one is re-copied from source via the sqlite
backup API (a consistent snapshot, even of a live DB) and re-asserted; a DB that
still cannot be made sound is a HARD cutover-blocking failure (default-deny: abort
before any source teardown, exit non-zero — a migration that cannot produce a valid
DB copy must not report success). os.walk skips symlinks, so shared skills are
untouched — only the agent's own copies. Two of these are guaranteed by verify.py
OUTCOME-ASSERTIONS, not by trusting the step ran: (1) no RECURRING source cron
stays enabled post-cutover (defect 11a), and (2) no migrated cron routes to the principal
by token OR prose (defect 14); the sqlite pass is itself prevent-plus-assert. Verify by OUTCOME,
never assume a step ran — a skilled operator hand-rescuing during a test masks a
missing step completely (this exact class-bug hid behind manual rescue across two
live migrations).
STEP 4 — PAUSE for the genuinely-human items only
List the needs-human set from the manifest/convert log and let the user pick handle-now or skip for each:
- the 3 dropped in-turn Telegram behaviors (re-home to the bus approvals flow),
- leaky custom skills to rewrite (hand to skill-creator),
- AGENTS.md de-dup after the fold,
- TOOLS.md Claude-auth-env flag. These are the items no script can safely auto-do (Step 4 below has the detail).
STEP 5 — FINAL REPORT (end-state aware)
Render the report (Step 6 below). Read the engine's end_state and lead with the
matching line:
- cutover: "-codex is live on the original bot; Claude is disabled."
- new-bot + boot: "-codex is live on its new bot; is still running."
- left disabled (default): "Ready — run
cortextos enable <name>-codexwhen you're happy."
Instance-discovery (routing is parameterized, not baked to any fleet)
cortextOS fleets often run a routing rule: principal-facing sends route THROUGH an orchestrator agent (the one that owns the principal's Telegram surface). This skill KEEPS that rule but discovers WHO the principal and orchestrator are for YOUR deployment — nothing is hardcoded. Three values, each discovered-then-safe-degraded:
| Value | convert.py param |
Discovery precedence | If unknown (safe-degrade) |
|---|---|---|---|
| Principal chat id | --principal-chat-id |
param -> source .env CHAT_ID -> ALLOWED_USER (if numeric) |
literal-id reroute leg OFF (nothing hunted) |
| Principal name | --principal-name |
param -> source .env ALLOWED_USER (if non-numeric) |
prose-intent reroute leg OFF (no false name match) |
| Orchestrator (reroute target) | --orchestrator <name> |
param -> auto-scan siblings for a POSITIVE role signal (config role/template==orchestrator OR an IDENTITY/SOUL self-declaration), exactly-one match only |
whole reroute OFF; principal-facing crons FLAGGED needs-human |
| Org | --org <org> |
param (no default) | — |
Key behaviors:
- The reroute is GATED on the orchestrator being known. A known principal chat id
with an UNKNOWN orchestrator does NOT rewrite anything (no send-into-the-void). Pass
--orchestrator <name>to enable routing, or the crons are surfaced for manual review. - Auto-scan never guesses. It requires a positive role declaration; it will NOT
pick the lone sibling in a 2-agent fleet. A single positive hit is SURFACED in the
report for you to confirm before trusting the reroute. In most fleets it finds
nothing and you simply pass
--orchestrator— that is the expected, honest path. - Guardrails split. The APPROVAL guardrail ("no external action without an explicit per-item go") is always injected. The ROUTING guardrail (naming the orchestrator) is injected ONLY when both orchestrator and principal are known.
- Double-unknown (no orchestrator AND no principal) emits ONE loud needs-human line in the report — routing reroute disabled; review crons manually.
The flow: DETECT -> MAP -> CONVERT -> VERIFY -> REPORT
/migrate-agent-to-codex <source-agent> [--org <org>] [--target-name <name>]
[--dry-run | --apply] # default: --dry-run
[--instance <id>] # default: default
[--orchestrator <name>] # reroute target; else auto-scan -> safe-degrade
[--principal-chat-id <id>] [--principal-name <name>] # else from source .env
Run the bundled scripts in order. They do the deterministic work; YOU do the
judgment work (the fold review, the leakage adjudication, the needs-human decisions).
Read references/ files when a step points you there.
Step 0 — Resolve paths and orient
# SKILL_DIR is the directory holding THIS SKILL.md and its scripts/. This file is
# READ by you, not executed, so there is no $0 — set it to the absolute path of the
# skill dir you are reading from (or `cd` into it and use `SKILL_DIR="$(pwd)"`).
SKILL_DIR="<absolute path to the dir containing this SKILL.md>"
SRC_AGENT="<source-agent>"
ORG="<org>" # your org (no default — pass it explicitly)
INSTANCE="default"
# CTX_ROOT comes from the source agent's .cortextos-env, NOT a guess:
CTX_ROOT="$(grep -E '^CTX_ROOT=' orgs/$ORG/agents/$SRC_AGENT/.cortextos-env | cut -d= -f2-)"
SRC_DIR="orgs/$ORG/agents/$SRC_AGENT"
# Migration-start cutoff for the write-side handoff gate: only a source handoff
# produced AFTER this timestamp satisfies the gate. Capture it ONCE, before the
# source is told to run handoff:create, and reuse it for convert.py + verify.py.
MIGRATION_START_TS="$(date +%s)"
Confirm the source is a Claude agent. Refuse if config.json.runtime is already
codex-app-server (nothing to migrate) or hermes (out of scope).
Step 1 — DETECT (always runs, even in dry-run)
python3 "$SKILL_DIR/scripts/detect.py" \
--src-dir "$SRC_DIR" --ctx-root "$CTX_ROOT" --agent "$SRC_AGENT" \
> /tmp/migrate-$SRC_AGENT-manifest.json
detect.py walks the whole agent dir and the relevant $CTX_ROOT state, classifies
every artifact into a bucket (one_to_one, transform, dropped, needs_human,
port_verbatim), greps custom skills for Claude-only leakage, and emits a JSON
manifest. The catch-all port_verbatim bucket guarantees no file is dropped just
because there is no rule for it.
Read the manifest. It is the heart of the dry-run. Pay attention to:
hard_stops— non-empty means STOP and report before any convert (see Step 2).needs_human— the blocking set; the migrated agent stays disabled until resolved.skills.customwithleakage_hits— these need your adjudication (Step 4).mcp.servers— each populated server is real host-config + secrets work.
Step 2 — Pre-flight hard stops (block before convert)
Surface these BEFORE writing anything. They are non-negotiable:
- Non-codex template — HUMAN pre-check (not automated). If the source was
scaffolded from
orchestrator,analyst, orm2c1-worker, there is no codex variant — do NOT migrate it (add-agentrejects the combo; hand-building a degraded agent is wrong). Thehermesruntime IS caught automatically (it shows up asconfig.json.runtime == hermesand detect.py hard-stops on it). The other three are NOT reliably auto-detectable: no template field is persisted in config.json, the runtime is plainclaude-code, and identity files diverge from the template after customization, so by migration time they look like an ordinaryagentscaffold. detect.py emits a best-effort ADVISORY note when source files resemble one of these templates, but YOU must confirm the source's lineage before proceeding — this skill does not (and cannot) mechanically enforce it. - Target name collision. If
--target-nameresolves to an existing dir,add-agentrefuses. Same-name reuse requires disabling/renaming the source FIRST — ask, never auto-clobber. Safe default:--target-name <source>-codex. - MCP host-global name collision. Codex MCP server names are host-wide (NOT
<agent>__-namespaced like skills). If the source's.mcp.jsondeclares a server whose name already exists in~/.codex/config.tomlfor a DIFFERENT definition, that is a collision a human must resolve. HARD STOP until decided. - BOT_TOKEN reuse. The source
.envBOT_TOKEN cannot be shared by two live agents (TelegramgetUpdatesconflict). The target needs EITHER a new bot token OR the source disabled first. Decide before enable. Do not copy a live BOT_TOKEN into a second agent that will run concurrently.
Step 3 — CONVERT (only on --apply)
MANDATORY, ORDERED prerequisite: lay the codex skeleton with add-agent
FIRST, then run convert. This is not optional and not reversible in order: convert
overlays onto the skeleton and hard-refuses (exit 1) if
.agents/plugins/marketplace.json is absent in the target. Reusing add-agent
gets you the marketplace.json, the 23-skill codex stdlib, symlink wiring, config
defaults, a correct per-agent .cortextos-env, org-context seeding, and
enabled-agents registration for free.
TARGET="${TARGET_NAME:-${SRC_AGENT}-codex}"
cortextos add-agent "$TARGET" --template agent-codex --runtime codex-app-server --org "$ORG"
# add-agent MUST have created orgs/$ORG/agents/$TARGET/.agents/plugins/marketplace.json
# before convert will proceed.
Then run the converter, which overlays everything the skeleton does not provide:
python3 "$SKILL_DIR/scripts/convert.py" \
--manifest /tmp/migrate-$SRC_AGENT-manifest.json \
--src-dir "$SRC_DIR" \
--target-dir "orgs/$ORG/agents/$TARGET" \
--target-agent "$TARGET" \
--ctx-root "$CTX_ROOT" \
--source-agent "$SRC_AGENT" --migration-start-ts "$MIGRATION_START_TS" \
--apply
# --migration-start-ts is the write-side handoff-gate cutoff (STEP 2b); on a
# cutover convert.py BLOCKS up to --handoff-wait (default 300s) for a source
# handoff newer than it, and ABORTS the cutover if none appears.
# Interactive-flow flags from STEP 2 (omit all for the SAFE default:
# source untouched, codex disabled, no boot — exactly the legacy behavior):
# --bot-mode reuse # CUTOVER: disable source, copy its bot
# # token to codex, IMPLY boot (one bot)
# --bot-mode new --new-bot-token T # write a fresh token; both run side-by-side
# --boot now # enable+start codex (implied by reuse)
# --source-agent "$SRC_AGENT" --org "$ORG" --instance "$INSTANCE"
# # required for reuse-cutover + boot
Flag semantics (engine): all four flags default to the SAFE path. --bot-mode none (default) and --boot no (default) = source untouched, codex disabled, no
token change — byte-identical to running with no flags at all. The destructive
parts ONLY happen when the user-confirmed flags are present:
--bot-mode reuse-> (i) sourceconfig.jsonenabled:false AND source registry entry enabled:false (the cutover source-disable), (ii) source BOT_TOKEN copied into the codex.env(0600), (iii) boot implied.--bot-mode new-> writes--new-bot-tokeninto the codex.env; if the token is omitted it writes aREPLACE_ME_WITH_NEW_BOT_TOKENplaceholder and emits a needs-human line. Source is NOT touched.--boot now(or implied by reuse) -> flips codexconfig.json+ registry to enabled:true and runscortextos enable <target> --org <org>to start it. The engine prints anend_state(disabled|live|cutover-live) on stdout; STEP 5 of the report keys off it.
convert.py performs the deterministic transforms (detailed per-artifact in the next
section). It does NOT make judgment calls: it stages the CLAUDE.md fold for your
review, copies leakage-clean custom skills, rewrites mechanical refs, transforms
.mcp.json into TOML it prints for you to merge, and drops the .force-fresh marker.
After it runs, do the human steps it flags (fold review, leakage rewrites, MCP merge,
symlink install).
By default it leaves the agent DISABLED in BOTH places. add-agent registers the
new agent in the instance registry $CTX_ROOT/config/enabled-agents.json as
enabled:true, and the daemon reads THAT registry (not the agent-dir config.json) for
live enable state. So setting only config.json enabled:false would leave a migrated
agent LIVE. With no interactive flags, convert.py flips the target's registry entry to
enabled:false too (merge-only, touching no other agent's key), and verify.py asserts
both — enabling is then a separate explicit step. The ONLY way the target boots (or the
source gets disabled) is when the user-confirmed STEP 2 flags are passed: --boot now
re-enables the target in both places and starts it via cortextos enable; --bot-mode reuse additionally disables the SOURCE in both places (the cutover). Absent those flags,
nothing is enabled and the source is never touched.
Step 4 — Human judgment steps (the parts no script can do)
- Review the folded AGENTS.md.
convert.pyappends the source CLAUDE.md's substantive content under a clearly-labeled section. A blind concat can put contradictory instructions into the ONE file codex auto-reads. Read the merged AGENTS.md end to end, de-duplicate overlapping sections (session-start protocol, etc.), and resolve conflicts in favor of the codex-tuned wording. This is mandatory. - Adjudicate leakage-flagged custom skills. For each custom skill with
leakage_hits: if the hits are mechanically rewritable (a.claude/skills/xpath, anANTHROPIC_API_KEYmention)convert.pyalready rewrote them — confirm. If the skill embeds Claude-Code-only BEHAVIOR (relies onExitPlanMode, the ClaudeAgenttool's subagent types, ormcp__<x>__*tool calls), it is NEEDS-HUMAN: hand it to theskill-creatorskill for a codex rewrite. Do not ship a broken skill — it degrades the codex agent on first boot. Note: grep-clean is necessary, not sufficient; a skill can pass the grep and still depend on Claude tool semantics, so a boot-probe (Step 5 Tier 2) is the real confirmation. - Install the symlinks. After custom skills are in
plugins/cortextos-agent-skills/skills/, ensure every one has a host symlink:~/.codex/skills/<target>__<skill>. Re-runningcortextos add-agent's symlink path is the cleanest way;convert.py --applyalso creates them directly. The installer ONLY walksplugins/cortextos-agent-skills/skills/*— so put ALL custom skills there, never in.agents/skills/(which gets no symlink). Symlinks are REQUIRED, not optional; do not rely on cwd-relative discovery masking a miss. - Merge MCP servers + provision secrets. For each populated MCP server,
convert.pyprinted a[mcp_servers.<name>]TOML block. MERGE it into the host-global~/.codex/config.toml(never overwrite the file — it is shared by all codex agents). The secret is NOT in the block: it is referenced viabearer_token_env_var. Add that env var toorgs/<org>/secrets.env(or the shell profile). Codex resolves it at app-server startup. - Verify host model auth exists. Codex model auth is host-global
(
~/.codex/auth.json), selected asmodelin~/.codex/config.toml. VERIFY it exists (codexis logged in); NEVER mint or copy model credentials per agent. The agent.envstays Telegram/bus/tool secrets only — no gpt-5.5 auth in.env.cortextos doctorcovers host-level auth/binary checks; reuse it.
Step 5 — VERIFY
THE GATE (NON-NEGOTIABLE). The migration is NOT "done" until verify.py exits
0. Every step before this is best-effort; verify.py is the OUTCOME-ASSERTION that
the agent actually WORKS after cutover, not merely that files COPIED. A non-zero exit
is a HARD STOP: do NOT report success, do NOT enable the target, do NOT tell the user
"migrated" — fix the failing assertion (or surface it as a blocking needs-human) and
re-run until it passes. Verify is mandatory and runs on BOTH the dry-run and the
--apply pass; the --apply run's green is the one that authorizes "done".
python3 "$SKILL_DIR/scripts/verify.py" \
--target-dir "orgs/$ORG/agents/$TARGET" --target-agent "$TARGET" --ctx-root "$CTX_ROOT" \
--source-dir "$SRC_DIR" --source-agent "$SRC_AGENT" \
--migration-start-ts "$MIGRATION_START_TS"
# --source-dir/--source-agent enable the cron-UNION assertion (and the cutover
# source-disable check); --migration-start-ts enables the fresh-handoff assertion.
# Match the assertion to the end state the user chose in STEP 2:
# (no flag) default -> assert codex DISABLED in config.json + registry
# --expect-boot user chose --boot now (new-bot path) -> assert codex ENABLED
# --expect-cutover user chose bot reuse -> assert codex ENABLED *and* the
# SOURCE disabled in both config.json + registry
- Tier 1 (static, always). Asserts the codex tree is well-formed:
config.json.runtime == codex-app-server,modelpresent, the codex enabled-state matches the chosen end state (DEFAULT:enabled:falsein config.json AND in the registry$CTX_ROOT/config/enabled-agents.json— the daemon reads the registry, so it is the load-bearing assertion; with--expect-boot/--expect-cutoverit instead assertsenabled:truein both, and--expect-cutoveradditionally asserts the SOURCE isenabled:falsein both), NOCLAUDE.md, NO.claude/settings.json, AGENTS.md present, every custom skill present underplugins/.../skills/AND symlinked,.envpresent and 0600, no stalecodex-app-server-thread.json(and no*thread.json),.force-freshmarker present. ALSO asserts the migration invariants:state/<target>/.onboardedpresent (no cold-onboard); the source-generated fresh handoff is the NEWEST file instate/<target>/handoffs/and.handoff-doc-pathreferences it; the target cron set matches the source union (no drops) and every cron's cd/skills path resolves in the target workspace; no migrated cron sends direct to the principal; the routing+approval guardrails are present in the target bootstrap (GUARDRAILS.md+approval_rules). ALSO the does-it-WORK assertions: every custom skill symlink RESOLVES (not just exists — a dangling/mis-targeted link FAILS, since Codex can't load it); every MCP server the source declared is installed as[mcp_servers.<name>]in~/.codex/config.tomlwith its bearer token insecrets.env(else a live 401); every migrated*.dbpasses a sqliteintegrity_check(no torn live-copy); and on--expect-cutover: the recurring source crons are disabled in the sourceconfig.jsontoo (not just the registry — the config==registry drift guard), and NO sibling agent strands a hardcoded ref to the retiredagents/<source>path (it would read stale/empty post-cutover — repoint it toagents/<target>). - Tier 2 (live boot probe, only on explicit go). This enables the agent for a
bounded probe, watches the log, then disables it. It costs codex tokens and briefly
puts the agent live — never run it in dry-run, never without explicit user go.
Signals (from the adapter):
- boot OK: log contains
[codex-app-server] ready thread=<id>(BOOTSTRAP_PATTERN). - boot FAIL: log contains
[codex-app-server] degraded:. - loop OK: a
turn/completedfires andlast_idle.flagis written after a trivial probe turn. - skills wired:
skills/listreturns the expected custom-skill names. This is a boot probe, not a soak. Label it as such; do not claim a soak passed.
- boot OK: log contains
Step 6 — REPORT
Render references/report-template.md, filled from the manifest + convert log +
verify results. Rules:
- Lossy / dropped items are NEVER silent. Every dropped or transformed artifact gets a line with a reason and the codex-native alternative (or an honest "lost").
- Order by severity: hard-stops first, then dropped (no-equivalent), then needs-human, then transformed, then the clean 1:1 list last. Do not bury a dropped behavior under green checkmarks.
- Separate "done" from "needs you." The needs-human section is the blocking set; the agent stays disabled until those are resolved.
- The dry-run report IS the approval artifact: the user reads it, then re-runs
--applywith the STEP 2 flags. LEAD with the END STATE line matching the engine'send_state(cutover-live / live / disabled — see report-template.md). - For the DISABLED end state, end with the exact enable command, gated on review:
cortextos enable <target> --org <org>. For LIVE / CUTOVER-LIVE, the agent is already booting — give the boot-probe + rollback commands from the template instead.
Per-artifact handling (the mapping)
This is the lookup the scripts implement. Use it to read the manifest and the report.
config.json — TRANSFORM (mechanical)
Flip runtime "claude-code" -> "codex-app-server" (this is the ONLY field the
daemon branches on to select the codex PTY — setting it wrong silently keeps the
claude PTY). Set model to a codex model (gpt-5-codex template default; gpt-5.5
also valid). DROP dangerously_skip_permissions (meaningless — codex hardcodes
approvalPolicy:'never' / danger-full-access). DROP the ecosystem block (the
codex template does not carry it). ADD codex_context_cap (default 256000) so codex
has a context-window fallback when the app-server reports null modelContextWindow.
CARRY all runtime-agnostic knobs from the source: timezone, day_mode_*,
communication_style, approval_rules, startup_delay,
max_session_seconds, max_crashes_per_day, crash_window, telegram_polling.
crons[] is NOT carried here — it is migrated separately (see Crons below)
because config.json can DIVERGE from the live registry.
agent_name is set to the TARGET name (not the source). working_directory is
NOT carried verbatim: if the source pins a launch path INSIDE its own agent dir,
the target would inherit the same ~/.claude/projects/<dashed-path>/ JSONL key
and share the source's stale session state (defeating the .force-fresh guard) —
that case is a HARD STOP, and the target is left with working_directory="" (its
own dir). A working_directory pointing OUTSIDE the source agent dir (a genuine
external repo) is carried.
Cost note: dashboard/src/lib/cost-parser.ts maps any model containing gpt-5 to
gpt-5-codex PRICING. So a gpt-5.5 agent is costed at gpt-5-codex rates. If gpt-5.5
has different real pricing, flag it as needs-human (cost rows will be wrong until a
dedicated gpt-5.5 pricing entry is added) — the migration does not block on it but
the report must name it.
CLAUDE.md -> AGENTS.md fold — TRANSFORM + NEEDS-HUMAN review
Codex has NO CLAUDE.md; AGENTS.md is the only file codex auto-reads. If the source
CLAUDE.md is a thin @AGENTS.md wrapper, just drop it. If it has real per-agent
content (inbox routing, graphify rules, etc.), fold that content into AGENTS.md under a
labeled heading, rewrite Claude-isms during the fold (.claude/skills/x paths,
~/.claude/ mentions, CLAUDE.md-cascade phrasing), then DELETE CLAUDE.md from the
target. The concat is automatic; the semantic de-dup is a mandatory human review.
Skills relayout — TRANSFORM
.claude/skills/<skill>/ -> plugins/cortextos-agent-skills/skills/<skill>/SKILL.md.
Diff the source skill set against the 23-skill codex stdlib (see
references/codex-bundled-skills.txt): skills already in the bundle come for free via
add-agent — do NOT re-copy. RE-EXAMINE any skill that is a Claude-STANDARD/bundled
skill but absent from the codex 23 (e.g. one-big-feature, skill-creator, docx,
code-review): the grep treats it as plain custom, but it can carry Claude-specific
orchestration semantics (subagent types, plan-mode loops, ExitPlanMode) that a token
grep misses. detect.py flags these as needs-human; confirm a codex equivalent exists
or rewrite via skill-creator before shipping. For each CUSTOM skill (not in the bundle):
leakage-grep first (references/leakage-tokens.txt); if clean, copy the whole dir verbatim
(SKILL.md + resources/ + templates/); if it has mechanically-rewritable refs, copy +
rewrite; if it embeds non-portable Claude behavior, NEEDS-HUMAN -> skill-creator.
Then convert.py re-runs the FULL symlink install over EVERY dir in
plugins/cortextos-agent-skills/skills/* (bundled + custom), creating/refreshing
~/.codex/skills/<target>__<skill> for each — idempotent, and it never clobbers a
real (non-symlink) file at the link path. This is deliberate: verify.py asserts a
symlink for every skill dir incl the 23 bundled ones, and add-agent's installer
silently no-ops on a pre-existing non-symlink, so the scripted path must guarantee
the links itself rather than assume them. The marketplace.json registration ships
with the codex skeleton from add-agent.
Hooks (.claude/settings.json) — mostly RE-EXPRESSED, three DROPPED
DELETE .claude/settings.json; no codex file replaces it. See
references/hooks-disposition.md for the per-hook table. Summary: the allowlist and
auto-allow are SUPERSEDED by codex's hardcoded autonomy; idle-flag, context-status,
crash-alert, typing, and boot-notify are RE-EXPRESSED natively by the adapter/daemon
(NOT lost); only three behaviors genuinely DROP — plan-mode->Telegram,
AskUserQuestion->Telegram, PreCompact->Telegram. Re-home those to the bus approvals
flow and report them as needs-human. NEVER emit a blanket "hooks lost."
MCP (.mcp.json) -> ~/.codex/config.toml [mcp_servers.] — TRANSFORM + NEEDS-HUMAN
Empty .mcp.json -> no-op (report "no MCP servers"). Populated -> for each server emit
a [mcp_servers.<name>] TOML table into the HOST-GLOBAL ~/.codex/config.toml (MERGE,
never overwrite). Each server sets EITHER command (stdio) OR url (HTTP). HTTP:
url + bearer_token_env_var (the token is referenced, never inlined). stdio:
command, args, env (canonical sub-table [mcp_servers.<id>.env] with KEY = "VALUE" lines, per OpenAI docs), cwd, enabled, startup_timeout_sec (alias
startup_timeout_ms), tool_timeout_sec, required, enabled_tools,
disabled_tools, default_tools_approval_mode (auto|prompt|approve).
bearer_token_env_var is HTTP-only. Secrets move to env vars (not auto-carried).
See references/mcp-toml-keys.md. Host-global name collisions are a hard stop (Step 2).
…(truncated)