diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx
index e73e838..17ff0b1 100644
--- a/apps/web/src/app/docs/page.tsx
+++ b/apps/web/src/app/docs/page.tsx
@@ -465,8 +465,8 @@ claude:
model: sonnet
codex:
model: gpt-5-codex
- reasoningEffort: medium
- webSearch: true
+ model_reasoning_effort: medium
+ web_search: true
---
You are a strict code reviewer. Check for:
@@ -477,9 +477,19 @@ You are a strict code reviewer. Check for:
Provider-specific blocks (claude:,{" "}
- codex:, etc.) let you tune model,
- reasoning effort, and other settings per tool without duplicating
- the prompt.
+ codex:, etc.) let you tune model and
+ other settings per tool without duplicating the prompt. Codex
+ config is passed through as a thin layer, so native snake_case
+ keys like approval_policy,{" "}
+ approvals_reviewer, and{" "}
+ web_search work directly. Existing
+ camelCase aliases are still normalized for backward
+ compatibility. Spawn-time controls like{" "}
+ fork_context and{" "}
+ fork_turns are not part of current
+ Codex static per-role schemas, though, so current Codex itself
+ will warn or ignore them if you place them under{" "}
+ codex: today.
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 7f1fda8..7d3ec4a 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -202,13 +202,24 @@ claude:
model: sonnet
codex:
model: gpt-5-codex
- reasoningEffort: medium
- webSearch: true
+ model_reasoning_effort: medium
+ web_search: true
---
You are a strict reviewer...
```
+For Codex, provider-native config keys are passed through to the generated
+`.codex/agents/.toml` as a thin layer. Prefer Codex's native snake_case
+keys such as `approval_policy`, `approvals_reviewer`, or `web_search`. Existing
+camelCase aliases like `reasoningEffort` are still normalized for
+backward-compatibility.
+
+Current `codex-cli 0.121.0` exposes spawn-time controls such as `fork_context` and
+`fork_turns` only on the `spawn_agent` tool path, not in static role/config
+files, so Codex itself will warn or ignore them if you place them under
+`codex:` in canonical frontmatter today.
+
## Command schema
Canonical commands are markdown files. Frontmatter is optional. When present,
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 68cae7a..2504366 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "agentloom",
- "version": "0.1.12",
+ "version": "0.1.13",
"description": "Unified agent and MCP sync CLI for multi-provider AI tooling",
"type": "module",
"bin": {
diff --git a/packages/cli/src/core/codex.ts b/packages/cli/src/core/codex.ts
new file mode 100644
index 0000000..131c151
--- /dev/null
+++ b/packages/cli/src/core/codex.ts
@@ -0,0 +1,101 @@
+import { isObject } from "./fs.js";
+
+const MANAGED_INSTRUCTION_KEYS = new Set([
+ "developerInstructions",
+ "developer_instructions",
+ "modelInstructionsFile",
+ "model_instructions_file",
+]);
+
+const LEGACY_CODEX_ALIASES: Record = {
+ approvalPolicy: "approval_policy",
+ reasoningEffort: "model_reasoning_effort",
+ reasoningSummary: "model_reasoning_summary",
+ sandboxMode: "sandbox_mode",
+ verbosity: "model_verbosity",
+ webSearch: "web_search",
+};
+
+export function normalizeCodexConfigForToml(
+ providerConfig: Record,
+): Record {
+ const normalized = normalizeCodexValueForToml(providerConfig);
+ if (!isObject(normalized)) {
+ return {};
+ }
+
+ for (const key of MANAGED_INSTRUCTION_KEYS) {
+ delete normalized[key];
+ }
+
+ return normalized;
+}
+
+export function cloneCodexProviderConfig(
+ providerConfig: Record,
+): Record {
+ const cloned = cloneCodexValue(providerConfig);
+ if (!isObject(cloned)) {
+ return {};
+ }
+
+ return cloned;
+}
+
+export function stripManagedCodexInstructionKeys(
+ providerConfig: Record,
+): Record {
+ const cleaned = cloneCodexProviderConfig(providerConfig);
+
+ for (const key of MANAGED_INSTRUCTION_KEYS) {
+ delete cleaned[key];
+ }
+
+ return cleaned;
+}
+
+function normalizeCodexValueForToml(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map((entry) => normalizeCodexValueForToml(entry));
+ }
+
+ if (!isObject(value)) {
+ return value;
+ }
+
+ return Object.fromEntries(
+ Object.entries(value).map(([key, entry]) => [
+ toSnakeCase(key),
+ normalizeCodexValueForToml(entry),
+ ]),
+ );
+}
+
+function cloneCodexValue(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map((entry) => cloneCodexValue(entry));
+ }
+
+ if (!isObject(value)) {
+ return value;
+ }
+
+ return Object.fromEntries(
+ Object.entries(value).map(([key, entry]) => [key, cloneCodexValue(entry)]),
+ );
+}
+
+function toSnakeCase(key: string): string {
+ if (Object.prototype.hasOwnProperty.call(LEGACY_CODEX_ALIASES, key)) {
+ return LEGACY_CODEX_ALIASES[key];
+ }
+
+ if (!/[A-Z]/.test(key)) {
+ return key;
+ }
+
+ return key
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
+ .toLowerCase();
+}
diff --git a/packages/cli/src/core/migration.ts b/packages/cli/src/core/migration.ts
index a2e3b81..7b7c2bb 100644
--- a/packages/cli/src/core/migration.ts
+++ b/packages/cli/src/core/migration.ts
@@ -6,6 +6,10 @@ import TOML from "@iarna/toml";
import matter from "gray-matter";
import YAML from "yaml";
import { buildAgentMarkdown, parseAgentsDir } from "./agents.js";
+import {
+ cloneCodexProviderConfig,
+ stripManagedCodexInstructionKeys,
+} from "./codex.js";
import {
normalizeCommandArgumentsForCanonical,
parseCommandsDir,
@@ -592,35 +596,9 @@ function readCodexProviderAgents(paths: ScopePaths): ProviderAgentRecord[] {
? roleEntry.description.trim()
: roleName;
- const providerConfig: Record = {};
- if (typeof roleToml.model === "string") {
- providerConfig.model = roleToml.model;
- }
- if (typeof roleToml.model_reasoning_effort === "string") {
- providerConfig.reasoningEffort = roleToml.model_reasoning_effort;
- }
- if (typeof roleToml.model_reasoning_summary === "string") {
- providerConfig.reasoningSummary = roleToml.model_reasoning_summary;
- }
- if (typeof roleToml.model_verbosity === "string") {
- providerConfig.verbosity = roleToml.model_verbosity;
- }
- if (typeof roleToml.approval_policy === "string") {
- providerConfig.approvalPolicy = roleToml.approval_policy;
- }
- if (typeof roleToml.sandbox_mode === "string") {
- providerConfig.sandboxMode = roleToml.sandbox_mode;
- }
- if (typeof roleToml.web_search === "boolean") {
- providerConfig.webSearch = roleToml.web_search;
- }
- if (
- typeof providerConfig.webSearch !== "boolean" &&
- isObject(roleToml.tools) &&
- typeof roleToml.tools.web_search === "boolean"
- ) {
- providerConfig.webSearch = roleToml.tools.web_search;
- }
+ const providerConfig = stripManagedCodexInstructionKeys(
+ cloneCodexProviderConfig(roleToml),
+ );
records.push({
provider: "codex",
diff --git a/packages/cli/src/sync/index.ts b/packages/cli/src/sync/index.ts
index f5f35e2..1be4319 100644
--- a/packages/cli/src/sync/index.ts
+++ b/packages/cli/src/sync/index.ts
@@ -21,6 +21,7 @@ import {
parseCommandsDir,
renderCommandForProvider,
} from "../core/commands.js";
+import { normalizeCodexConfigForToml } from "../core/codex.js";
import {
parseRulesDir,
renderRuleForCursor,
@@ -1279,6 +1280,13 @@ function resolveCodexDeveloperInstructions(
return providerConfig.developerInstructions.trim();
}
+ if (
+ typeof providerConfig.developer_instructions === "string" &&
+ providerConfig.developer_instructions.trim() !== ""
+ ) {
+ return providerConfig.developer_instructions.trim();
+ }
+
return agentBody.trimStart().trimEnd();
}
@@ -1286,38 +1294,8 @@ function buildCodexRoleToml(
developerInstructions: string,
providerConfig: Record,
): Record {
- const roleToml: Record = {
- developer_instructions: developerInstructions,
- };
-
- if (typeof providerConfig.model === "string") {
- roleToml.model = providerConfig.model;
- }
-
- if (typeof providerConfig.reasoningEffort === "string") {
- roleToml.model_reasoning_effort = providerConfig.reasoningEffort;
- }
-
- if (typeof providerConfig.reasoningSummary === "string") {
- roleToml.model_reasoning_summary = providerConfig.reasoningSummary;
- }
-
- if (typeof providerConfig.verbosity === "string") {
- roleToml.model_verbosity = providerConfig.verbosity;
- }
-
- if (typeof providerConfig.approvalPolicy === "string") {
- roleToml.approval_policy = providerConfig.approvalPolicy;
- }
-
- if (typeof providerConfig.sandboxMode === "string") {
- roleToml.sandbox_mode = providerConfig.sandboxMode;
- }
-
- if (typeof providerConfig.webSearch === "boolean") {
- roleToml.web_search = providerConfig.webSearch;
- }
-
+ const roleToml = normalizeCodexConfigForToml(providerConfig);
+ roleToml.developer_instructions = developerInstructions;
return roleToml;
}
diff --git a/packages/cli/tests/unit/migration.test.ts b/packages/cli/tests/unit/migration.test.ts
index 44d19f6..cefa9b4 100644
--- a/packages/cli/tests/unit/migration.test.ts
+++ b/packages/cli/tests/unit/migration.test.ts
@@ -1454,7 +1454,7 @@ Review changed files and summarize findings.
);
writeTextAtomic(
path.join(paths.workspaceRoot, ".codex", "agents", "researcher.toml"),
- `model = "gpt-5.4"\ndeveloper_instructions = "Use concise bullets."\nmodel_reasoning_effort = "low"\nmodel_reasoning_summary = "auto"\nmodel_verbosity = "high"\napproval_policy = "never"\nsandbox_mode = "workspace-write"\nweb_search = true\n`,
+ `model = "gpt-5.4"\ndeveloper_instructions = "Use concise bullets."\nmodel_reasoning_effort = "low"\nmodel_reasoning_summary = "auto"\nmodel_verbosity = "high"\napproval_policy = "never"\nsandbox_mode = "workspace-write"\nweb_search = true\napprovals_reviewer = "guardian_subagent"\n`,
);
const summary = await migrateProviderStateToCanonical({
@@ -1472,12 +1472,13 @@ Review changed files and summarize findings.
"utf8",
);
expect(canonical).toContain("model: gpt-5.4");
- expect(canonical).toContain("reasoningEffort: low");
- expect(canonical).toContain("reasoningSummary: auto");
- expect(canonical).toContain("verbosity: high");
- expect(canonical).toContain("approvalPolicy: never");
- expect(canonical).toContain("sandboxMode: workspace-write");
- expect(canonical).toContain("webSearch: true");
+ expect(canonical).toContain("model_reasoning_effort: low");
+ expect(canonical).toContain("model_reasoning_summary: auto");
+ expect(canonical).toContain("model_verbosity: high");
+ expect(canonical).toContain("approval_policy: never");
+ expect(canonical).toContain("sandbox_mode: workspace-write");
+ expect(canonical).toContain("web_search: true");
+ expect(canonical).toContain("approvals_reviewer: guardian_subagent");
expect(canonical).toContain("\n\nUse concise bullets.\n");
});
diff --git a/packages/cli/tests/unit/sync-codex.test.ts b/packages/cli/tests/unit/sync-codex.test.ts
index ea00c26..bc098de 100644
--- a/packages/cli/tests/unit/sync-codex.test.ts
+++ b/packages/cli/tests/unit/sync-codex.test.ts
@@ -117,6 +117,86 @@ describe("codex sync", () => {
);
});
+ it("passes through arbitrary codex role-file keys without a hardcoded allowlist", async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-sync-"));
+ tempDirs.push(root);
+
+ const agentsDir = path.join(root, ".agents", "agents");
+ ensureDir(agentsDir);
+
+ writeTextAtomic(
+ path.join(agentsDir, "researcher.md"),
+ `---\nname: researcher\ndescription: Research specialist\ncodex:\n model: gpt-5.4-mini\n approvals_reviewer: guardian_subagent\n approval_policy:\n granular:\n request_permissions: true\n sandbox_approval: false\n---\n\nUse structured bullet points.\n`,
+ );
+
+ const paths = buildScopePaths(root, "local");
+
+ await syncFromCanonical({
+ paths,
+ providers: ["codex"],
+ yes: true,
+ nonInteractive: true,
+ });
+
+ const roleTomlPath = path.join(root, ".codex", "agents", "researcher.toml");
+ const roleToml = TOML.parse(fs.readFileSync(roleTomlPath, "utf8")) as {
+ model?: string;
+ developer_instructions?: string;
+ approvals_reviewer?: string;
+ approval_policy?: {
+ granular?: {
+ request_permissions?: boolean;
+ sandbox_approval?: boolean;
+ };
+ };
+ };
+
+ expect(roleToml.model).toBe("gpt-5.4-mini");
+ expect(roleToml.developer_instructions).toBe(
+ "Use structured bullet points.",
+ );
+ expect(roleToml.approvals_reviewer).toBe("guardian_subagent");
+ expect(roleToml.approval_policy?.granular).toEqual({
+ request_permissions: true,
+ sandbox_approval: false,
+ });
+ });
+
+ it("does not hardcode a denylist for codex keys like fork_context or fork_turns", async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-sync-"));
+ tempDirs.push(root);
+
+ const agentsDir = path.join(root, ".agents", "agents");
+ ensureDir(agentsDir);
+
+ writeTextAtomic(
+ path.join(agentsDir, "researcher.md"),
+ `---\nname: researcher\ndescription: Research specialist\ncodex:\n model: gpt-5.4-mini\n fork_context: false\n fork_turns: none\n---\n\nUse structured bullet points.\n`,
+ );
+
+ const paths = buildScopePaths(root, "local");
+
+ await syncFromCanonical({
+ paths,
+ providers: ["codex"],
+ yes: true,
+ nonInteractive: true,
+ });
+
+ const roleTomlPath = path.join(root, ".codex", "agents", "researcher.toml");
+ const roleToml = TOML.parse(fs.readFileSync(roleTomlPath, "utf8")) as {
+ fork_context?: boolean;
+ fork_turns?: string;
+ developer_instructions?: string;
+ };
+
+ expect(roleToml.fork_context).toBe(false);
+ expect(roleToml.fork_turns).toBe("none");
+ expect(roleToml.developer_instructions).toBe(
+ "Use structured bullet points.",
+ );
+ });
+
it("removes codex role config when codex provider is disabled", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-sync-"));
tempDirs.push(root);