Chapter 19: External Migration and Backward Compatibility
Reading Contract: Use this chapter to answer one question: how can Codex import external agent configuration and history without inheriting the external runtime’s semantics? Track four owners: detection, conversion, native runtime refresh, and session-import ledger. Afterward, you should be able to distinguish a native artifact, an imported artifact, a skipped external construct, and a compatibility bridge.

Source boundary: named files, structs, functions, protocol request/response shapes, and import records are verified source only where this chapter links to the pinned Codex commit or this chapter’s Source Map. The broader rule that migration is “conservative translation” is surrounding contract inference from those visible conversion and ledger boundaries. This chapter does not claim to emulate any external agent’s private runtime.
Chapter 18 separated Codex-native extension planes into skills, plugins, connectors, and typed contributors. This chapter asks what happens when users arrive with artifacts that were not born inside those planes: external configs, commands, hooks, subagents, MCP servers, and JSONL sessions.
You are here: Codex can load native extension surfaces with explicit trust boundaries.
Problem: users bring useful external state, but that state may use different prompt rules, hook timing, command expansion, permissions, session schemas, and trust assumptions.
Mental model: migration is a one-way adapter into native Codex artifacts plus provenance; it is not a hidden compatibility mode inside every later turn.
Backward compatibility in an agent system is not only about accepting old API fields. It is also about accepting user history and local automation without allowing ambiguous behavior to become hidden authority. The migration layer should read a source artifact, recognize supported constructs, translate them into native shapes, skip unsupported or unsafe cases, and record enough metadata for users and tests to understand the result.
The invariant: after migration, the turn loop should run Codex-native tools, hooks, skills, MCP configs, and rollout history. It should not keep asking “what would the external runtime have done?“
1. Migration Starts As Detection, Not Mutation
The source exposes migration as typed items. ExternalAgentConfigMigrationItemType includes config, skills, AGENTS.md, plugins, MCP server config, subagents, hooks, commands, and sessions. Detection returns ExternalAgentConfigMigrationItem records with a description, optional cwd, and optional details.
Shape-level:
{
"item_type": "McpServerConfig",
"description": "Import external MCP server configuration",
"cwd": "/project",
"details": {
"mcp_servers": [{ "name": "github" }],
"commands": [],
"sessions": []
}
}
That is a detection report, not a mutation. The request processor’s detect() maps core migration items into protocol response items for the app-server client. The client can then ask to import selected items.
1.1 Import Has Foreground And Background Phases
ExternalAgentConfigRequestProcessor::import() shows the runtime shape:
selected migration items
-> validate pending session imports
-> import config-like items
-> refresh runtime config when needed
-> send import response
-> background plugin/session imports
-> clear plugin/skill caches if plugin imports ran
-> notify import completed
This split is important. Some imports produce immediate local config changes. Some plugin and session work can continue in the background. The processor sends a response before background imports finish, then emits ExternalAgentConfigImportCompleted when the remaining work ends. A migration UI therefore should not treat “request accepted” as “every plugin and session is already imported.”
2. Config Migration Is Translation Into Native Shapes
External configuration migration handles several artifact families:
| Source artifact | Native destination | Verified source surface | Conservative rule |
|---|---|---|---|
| MCP server entries | Codex MCP config | build_mcp_config_from_external() | import supported server records and preserve merge rules |
| hooks | Codex hook config | import_hooks() | copy only representable hook mappings and scripts |
| commands | skills/workflow units | import_commands() | require parseable documents and bounded metadata |
| subagents | native agent definitions | import_subagents() | require stable frontmatter and safe target names |
| plugins | plugin config/store | pending plugin import path | split local and remote import work |
The converter source makes the conservative style concrete. build_mcp_config_from_external() reads external MCP server maps, applies enabled/disabled server lists, and returns a TOML table rooted at mcp_servers. import_commands() creates skill directories only for supported, unique command sources and skips existing targets. import_subagents() does the same for agent files.
Shape-level, the boundary is lossy by design:
external command document
-> parse frontmatter and body
-> derive stable skill name and description
-> skip if target already exists
-> write native SKILL.md
external dynamic behavior
-> no safe native representation
-> skip and report
The importer should never guess a permission boundary. If a source command relies on dynamic provider-specific expansion that Codex cannot represent, the safe import result is “skipped”, not “smuggled into a shell hook.”
3. Session Import Preserves History As Evidence
Session import is different from config migration because history is not a future capability. It is evidence of past conversation. The session importer has to translate enough of an external JSONL transcript into Codex rollout items that a thread can be listed, resumed, and audited, while still marking the source as imported.

The verified lifecycle is:
- Detection summarizes candidate sessions only when records contain a cwd, at least one user/assistant message, and a latest timestamp.
summarize_session()builds that summary. - Import validation canonicalizes selected paths and rejects sessions outside the detected external projects root.
validate_pending_session_imports()also deduplicates selected canonical paths. prepare_validated_session_imports()skips sessions already recorded in the content ledger and loads only importable sessions whose cwd still exists.load_importable_session()is the visible cwd check.- The request processor imports the session by starting a new Codex thread with
InitialHistory::Forked(rollout_items), then optionally normalizes the title into thread metadata. - After success,
record_imported_session()writes source path, content SHA-256, imported thread ID, and timestamp into a ledger.
3.1 The JSONL Shape Changes At The Boundary
The parser is intentionally selective. conversation_message_from_record() accepts only user/assistant records, skips meta and sidechain records, extracts message content, and parses timestamps. extract_message_text() turns text blocks into text, tool-use blocks into notes, tool-result blocks into notes, ignores thinking blocks, and labels unsupported blocks.
Shape-level:
{
"type": "user",
"cwd": "/project",
"message": {
"content": [
{ "type": "text", "text": "Fix the parser" },
{ "type": "tool_result", "content": "long external output..." }
]
},
"isSidechain": false
}
becomes a Codex-importable conversation message such as:
{
"role": "user",
"text": "Fix the parser\n[external tool result: long external output...]",
"timestamp": "parsed timestamp if present"
}
This is a shape-level example, not the exact serialized rollout record. The key point is the invariant: imported history is useful as history, but it is not treated as a lossless replay of another runtime’s internal state.

3.2 The Ledger Is Content-Based, Not Just Path-Based
The session ledger protects idempotency without hiding changed source content. ImportedExternalAgentSessionRecord stores source path, content_sha256, imported thread ID, and import time. contains_current_source() recomputes the current file hash before deciding that a source has already been imported.
That avoids two bad extremes:
| Ledger design | Failure |
|---|---|
| path only | changed JSONL content is suppressed forever |
| no ledger | every migration prompt can duplicate the same session |
| path + content hash | unchanged sessions are skipped, changed sessions can be detected again |
4. Compatibility Bridges Should End At The Boundary
Migration is one bridge. Protocol compatibility is another. Earlier chapters introduced generated schemas, v1/v2 protocol coexistence, request aliases, experimental gates, and client-version behavior. Chapter 19’s general rule is that compatibility should be explicit about what it bridges and where it stops.
| Bridge | What it protects | What it must not do |
|---|---|---|
| schema aliases | older clients and stored events | hide incompatible semantics |
| experimental gates | unstable capabilities | make unstable fields look permanent |
| migration converters | user workflows from other tools | emulate another runtime forever |
| import ledgers | idempotent session import | suppress changed source content |
| provenance markers | auditability of imported history | pollute model context with private implementation detail |
codex-rs/app-server-protocol/src/protocol/mod.rs is a small but useful reminder: protocol compatibility is organized as a namespace with common pieces, mappers, thread history, and v1/v2 modules. Compatibility belongs at the protocol edge and migration edge, not scattered through core logic as “maybe this was an external thing.”
5. Failure Conditions Are The Product Contract
Migration fails most dangerously when it succeeds silently but changes meaning. The user needs a report, and the runtime needs clear failure categories.
| Failure | Boundary owner | Correct behavior |
|---|---|---|
| unsupported command shape | command converter | skip and report, do not invent execution semantics |
| existing native target | importer | preserve existing Codex file |
| invalid external JSONL | session parser | skip record/session rather than corrupt rollout history |
| cwd no longer exists | session import preparation | do not import a thread with unusable working context |
| stale session ledger | ledger hash check | recompute content hash before skipping |
| background plugin failure | import processor | warn and still send completion notification after background phase |
| config-like import ran | request processor | refresh runtime config and clear relevant caches |
The product contract is not “all external state becomes native.” It is “supported state becomes native, unsupported state remains outside with an explanation.”
Trace Ledger
| Question | Chapter 19 answer |
|---|---|
| Where is the user request now? | It can be backed by imported native config, imported skills/hooks/MCP entries, background plugin imports, or imported rollout history. |
| What carries it? | migration items, migration details, converted native artifacts, app-server import responses, InitialHistory::Forked, thread metadata patches, and the content-hash session ledger. |
| Who owns the next decision? | Detection reports candidates; converters decide supported native shapes; the request processor refreshes runtime config and runs background imports; native loaders own the result afterward. |
| What must remain invariant? | Migration must not overwrite native user work, inherit trust blindly, emulate another runtime forever, or treat external history as a lossless native replay. |
| What can fail here? | unsupported semantics, disabled/duplicate entries, existing target conflicts, invalid JSONL, missing cwd, stale or invalid ledger data, remote plugin errors, or source paths outside the detected import boundary. |
Apply This
- Detect before mutating. Use this for external state. Return typed migration items with descriptions before writing native files. Pitfall: silent migration that users cannot audit.
- Translate into native contracts. Use this for commands, hooks, subagents, MCP servers, and sessions. Convert only supported shapes. Pitfall: preserving foreign runtime assumptions as hidden behavior.
- Record lossy boundaries. Use this when importing histories. Keep provenance and ledger data, and make unsupported blocks explicit. Pitfall: pretending external JSONL is native rollout replay.
- Make idempotency content-aware. Use this for imports that can be repeated. Hash source content, not just paths. Pitfall: suppressing changed sessions or duplicating unchanged ones.
- End the bridge after import. Use this for compatibility code. Let native loaders own migrated artifacts after conversion. Pitfall: spreading compatibility branches through the core turn loop.
What Comes Next
Part V ends with a runtime that can consume external tools, load native extension packages, and migrate outside state into native contracts. Chapter 20 turns to coordination beyond one agent: multi-agent threads, graph edges, live status, and trace reconstruction.
Source Map
| Concept | Source anchor |
|---|---|
| External config model | codex-rs/app-server/src/config/external_agent_config.rs |
| Migration request processor | codex-rs/app-server/src/request_processors/external_agent_config_processor.rs |
| External migration converters | codex-rs/external-agent-migration/src/lib.rs |
| External session parser | codex-rs/external-agent-sessions/src/records.rs |
| External session ledger | codex-rs/external-agent-sessions/src/ledger.rs |
| TUI migration startup | codex-rs/tui/src/external_agent_config_migration_startup.rs |
| Protocol compatibility surface | codex-rs/app-server-protocol/src/protocol/mod.rs |
| Thread store | codex-rs/thread-store/src |