How I keep Claude Code and Codex in sync
Table of contents
I use both Claude Code and Codex regularly, and I want my working environment to be the same regardless of which one I launch. When I teach Claude a workflow through a skill, add a safety hook, or change my global instructions, Codex should learn the same thing, and vice versa.
The difficulty is that the two tools are configured differently. They use different instruction files, settings formats, hook events, hook payloads, permission systems, and skill metadata. I tried several approaches, and the only one I found to work reliably is to have the agents themselves maintain a separate version of each relevant file for each tool. Concretely, the setup
- treats the Claude and Codex versions of each instruction file, skill, and hook script as peer implementations of the same behavior, each adapted to its own tool’s syntax, discovery rules, and hook payloads; and
- uses hooks, a manifest, and a deterministic audit to surface any drift between the two versions before it gets committed.
The layout
Everything starts from a single version-controlled directory holding the agent configuration. It can live wherever you keep your dotfiles; what matters is that the Claude and Codex versions of each file sit next to each other:
agent-config/
|-- claude/
| |-- CLAUDE.md
| |-- hooks/
| `-- skills/
|-- codex/
| |-- AGENTS.md
| |-- config.toml
| |-- hooks.json
| |-- hooks/
| |-- rules/
| `-- skills/
|-- ai-config-sync.json
`-- bin/ai-config-sync
Most files under claude/ and codex/ come in pairs: CLAUDE.md with AGENTS.md, each Claude skill with its Codex counterpart, and each Claude hook script with a Codex hook script, whenever both tools expose a suitable event.1
The next step is to make the files each tool actually loads point into this directory. For Claude, I symlink the global instruction file and the skills directory:
ln -s "$AGENT_CONFIG/claude/CLAUDE.md" ~/.claude/CLAUDE.md
ln -s "$AGENT_CONFIG/claude/skills" ~/.claude/skills
Claude’s ~/.claude/settings.json stays a real file, because Claude itself writes to it. Claude’s hook registrations live in that file, but the commands they run point at scripts under $AGENT_CONFIG/claude/hooks/. This is why claude/ has no counterpart to codex/hooks.json or codex/config.toml.
For Codex, I symlink all the global files:
ln -s "$AGENT_CONFIG/codex/AGENTS.md" ~/.codex/AGENTS.md
ln -s "$AGENT_CONFIG/codex/config.toml" ~/.codex/config.toml
ln -s "$AGENT_CONFIG/codex/hooks.json" ~/.codex/hooks.json
ln -s "$AGENT_CONFIG/codex/rules" ~/.codex/rules
ln -s "$AGENT_CONFIG/codex/skills" ~/.codex/skills
Project-local configuration follows the same pairing pattern inside each project that has any:
some-project/
|-- CLAUDE.md
|-- AGENTS.md
|-- .mcp.json
|-- .claude/
| |-- settings.json
| |-- hooks/
| `-- skills/
`-- .codex/
|-- config.toml
|-- hooks.json
|-- hooks/
`-- skills/
These files belong to some-project alone, so nothing in the global ~/.claude/ or ~/.codex/ directories should point to them. Within the project, I pair the Claude and Codex sides of any local instructions, skills, hooks, or MCP servers: CLAUDE.md with AGENTS.md, .claude/skills/NAME/ with .codex/skills/NAME/, .claude/hooks/HOOK with .codex/hooks/HOOK, .claude/settings.json with .codex/hooks.json for hook registration, and .mcp.json with the relevant mcp_servers entries in .codex/config.toml for MCP wiring.
What counts as synchronized
Three kinds of content are kept in sync: instructions, skills, and hooks. There is also what I call the activation state: the wiring, including MCP server configuration, that makes each tool load that content. I treat it as wiring rather than as a fourth kind of content.
Instructions. Claude’s instruction files are named CLAUDE.md; Codex’s are named AGENTS.md. Globally, Claude reads claude/CLAUDE.md through the ~/.claude/CLAUDE.md symlink, and Codex reads codex/AGENTS.md through ~/.codex/AGENTS.md. Within projects, every CLAUDE.md has a sibling AGENTS.md. I used to configure Codex to fall back to CLAUDE.md when a project had no AGENTS.md, so that one file served both tools. This was convenient until a project needed Claude-specific syntax or operational detail, so I now keep separate instruction files everywhere. Before checking that two paired instruction files say the same thing, the audit and the commit guard (both described below) normalize forms that legitimately differ between the tools, such as a Claude @file import on its own line versus the plain path reference Codex needs instead.
Skills. A reusable global skill should normally exist in both claude/skills/NAME/ and codex/skills/NAME/. Project-local skills follow the same rule: .claude/skills/NAME/ pairs with .codex/skills/NAME/. The bodies and auxiliary files of the two versions should be equivalent. Before comparing them, the audit and the commit guard strip tool-specific frontmatter keys, such as allowed-tools, argument-hint, model, user-invocable, and argument metadata, from both sides. Runtime outputs (generated digests, last-run files, caches, and the like) are ignored.
Hooks. Hooks are paired when the two tools expose compatible events and payloads. Global Claude hook scripts live in claude/hooks/ and are registered in the untracked ~/.claude/settings.json. Global Codex hook scripts live in codex/hooks/ and are registered in the tracked codex/hooks.json, which Codex loads through ~/.codex/hooks.json. Project-local hooks follow the same convention: .claude/hooks/HOOK pairs with .codex/hooks/HOOK, and hook registration in .claude/settings.json pairs with .codex/hooks.json.2
Activation state. It is not enough for codex/hooks.json to exist in the repository: ~/.codex/hooks.json must point to it. Likewise, it is not enough for the Codex skills to be paired with their Claude counterparts: ~/.codex/skills must point to codex/skills/. The audit checks these global links, because otherwise the repository could look correct while the agent was reading stale local files.
Claude’s activation state works differently, by design. Its hook registrations and global settings live in ~/.claude/settings.json, which is not tracked, so on the Claude side what is tracked is the hook scripts themselves rather than the file that registers them.
MCP configuration is the other part of the activation state. Claude Code and Codex do not share it: Claude reads servers from .mcp.json or ~/.claude.json, while Codex reads its own config.toml. When both tools should have access to the same service, I treat their two entries as a pair. The audit checks what can safely be checked mechanically: that both sides define the server, that the public command, args, transport, and url fields match where they should, that the required environment-variable names are present, and that any service-specific identity checks pass (for example, that both tools are authenticated as the same account). It never compares or prints secret values.
The manifest
The file ai-config-sync.json is the manifest for the global configuration. It records:
- paired instruction files
- every global skill pair
- the pairing status of each hook
- MCP server wiring and identity checks
- the live files where hooks are registered
- runtime outputs that should be ignored
- Codex-only, Claude-only, unsupported, and intentionally divergent artifacts
The manifest is not a generator: it never rewrites one side from the other. When parity is possible, the manifest says which global files should match once tool-specific differences are normalized away. When parity is impossible or undesirable, the manifest says so explicitly.
This explicitness matters. Without it, a missing counterpart could mean either that the agent forgot to create it or that the feature has no counterpart in the other tool. With the manifest, every exception has to be documented.
Project-local instructions, skills, and hooks don’t need to be listed in the manifest. In whichever Git repository the agent is editing or committing, the sync script automatically treats the following as pairs:
PROJECT/CLAUDE.mdandPROJECT/AGENTS.mdPROJECT/.claude/skills/andPROJECT/.codex/skills/PROJECT/.claude/hooks/andPROJECT/.codex/hooks/PROJECT/.claude/settings.jsonandPROJECT/.codex/hooks.json- configured MCP pairs such as
PROJECT/.mcp.jsonandPROJECT/.codex/config.toml
The two hook families
Two families of hooks enforce the pairing. Reminder hooks tell the agent about the counterpart as soon as it edits one side. Enforcement hooks block a commit that would record a change to only one side.
After an agent edits a relevant file, the reminder hook runs bin/ai-config-sync in reminder mode, which adds a note like this to the agent’s context:
claude/skills/foo changed; update codex/skills/foo in the same session.
In Claude, this is a PostToolUse hook that wraps:
bin/ai-config-sync remind-claude
In Codex, it is the analogous hook that wraps:
bin/ai-config-sync remind-codex
Before an agent runs a Git commit command, the enforcement hook, require-ai-config-sync.sh, calls:
bin/ai-config-sync guard-commit
This commit guard inspects the staged files, as well as files that common one-shot forms such as git add ... && git commit, git add -A, and git commit -a would stage. It works out which Git repository the commit targets, so the same guard covers both the global pairs and project-local pairs (CLAUDE.md and AGENTS.md, .claude/ and .codex/). For skills, it doesn’t merely check that a file with the same name exists on the other side: it checks that the contents match once tool-specific frontmatter is removed from both.
If the guard finds a change to one side without a matching change to the other, it blocks the commit and tells the agent exactly what is missing. If a global artifact should have no counterpart, the agent must add an exemption for that specific artifact to ai-config-sync.json; there are no implicit exceptions.
The hooks never edit the counterpart themselves. Instead, they make synchronization part of the agent’s normal path to finishing a task: it must either update both sides or document why a global artifact has no counterpart.
The audit
Finally, there is a deterministic audit:
bin/ai-config-sync audit
The audit checks the manifest itself; topology invariants (which files may live where and what may point to what); the live symlinks; the equivalence of the global instruction files and of the dotfiles repository’s own project instruction files; the contents of global skills, after normalizing their frontmatter, and their auxiliary files; the inventory of hook scripts and their registrations; the configured MCP server pairs; and the Codex activation files. It edits nothing; its sole job is to make drift visible. It is also deliberately limited to the global configuration. Project-local pairs, other than the dotfiles repository’s instruction files, are checked only by the reminder and commit hooks, when an agent edits or commits inside the project.
The topology checks are especially useful because they catch the cases that are hardest to diagnose by hand:
- a global Codex hook registration placed under
dotfiles/.codex/hooks.json - symlinks in
~/.claudeor~/.codexthat resolve to a project’s local configuration - undocumented symlinks in
~/.claudeor~/.codex - paired MCP servers authenticated as different accounts
- skills local to the dotfiles repository that should really be global
The result is three layers of protection:
- live symlinks ensure that each tool reads the tracked files;
- post-edit reminders make the agent aware of the counterpart it needs to update; and
- the commit guard and the audit stop accidental one-sided changes from being committed, or flag them if they were.
What this buys me
In practice, I can switch between Claude Code and Codex without keeping two mental models of my working environment. Both agents follow the same global rules about verification, secrets, Git hygiene, external actions, and project layout; both can discover the same reusable skills; and each project’s instructions, skills, and hooks are paired as needed.
The main limitation is that the system only watches agents; it is not a general filesystem monitor. The reminders and the commit guard run only when Claude Code or Codex edits a file or attempts a commit, so if I edit these files by hand or commit from an ordinary shell, nothing fires. The audit still catches drift in the global configuration, but drift in project-local pairs goes unnoticed unless an agent made the edit or the commit. For my purposes this is acceptable, since agents make almost all the edits to these files.
Trying it yourself
To try the setup, give Claude Code or Codex this prompt:
Set up Claude Code <> Codex synchronization using https://github.com/benthamite/agent-sync-template.
- Clone the repository to a sensible local location if it is not already available.
- Run its smoke test before touching my live Claude or Codex configuration.
- Then follow BOOTSTRAP.md exactly.
The bootstrap is designed to be conservative: it runs the smoke test before touching any live files, preserves your existing settings, and should stop and ask before overwriting, deleting, or merging anything it can’t handle safely. The repository’s README.md has the details.
With thanks to Alejandro Acelas for beta testing and to GPT-5.5 for copy editing.
The remaining files under
codex/(config.toml,hooks.json, and therulesdirectory) are activation files rather than peer artifacts.hooks.jsonregisters the Codex hook scripts, andconfig.tomlholds Codex-wide settings, including the flag that enables hooks. Claude keeps its equivalent hook registrations and settings in~/.claude/settings.jsonand~/.claude/settings.local.json. Since Claude writes to these files itself, I leave them as ordinary files rather than symlinking them into version control. ↩︎Some hooks can’t be paired one-to-one. Claude hooks can match the
Read,Grep,Glob, andSkilltools, which have no Codex equivalent in this setup. Claude’sNotificationhooks don’t map directly to Codex either; on the Codex side, lifecycle and notification-like behavior goes throughcodex-hook-wrapperentries incodex/hooks.json. Hooks specific to Chrome have no Codex counterpart at all. All these cases are recorded in thehookssection ofai-config-sync.json, with statuses such asclaude-only,codex-only, orunsupported, so that they aren’t flagged as unexplained drift. ↩︎