feat: add codex cli backend

This commit is contained in:
Peter Steinberger
2026-01-11 01:35:23 +00:00
parent 2cc0d8c058
commit 02270abc87
8 changed files with 277 additions and 35 deletions

View File

@@ -26,6 +26,12 @@ You can use Claude CLI **without any config** (Clawdbot ships a built-in default
clawdbot agent --message "hi" --model claude-cli/opus-4.5
```
Codex CLI also works out of the box:
```bash
clawdbot agent --message "hi" --model codex-cli/gpt-5.2-codex
```
If your gateway runs under launchd/systemd and PATH is minimal, add just the
command path:
@@ -133,7 +139,12 @@ The provider id becomes the left side of your model ref:
## Sessions
- If the CLI supports sessions, set `sessionArg` (e.g. `--session-id`).
- If the CLI supports sessions, set `sessionArg` (e.g. `--session-id`) or
`sessionArgs` (placeholder `{sessionId}`) when the ID needs to be inserted
into multiple flags.
- If the CLI uses a **resume subcommand** with different flags, set
`resumeArgs` (replaces `args` when resuming) and optionally `resumeOutput`
(for non-JSON resumes).
- `sessionMode`:
- `always`: always send a session id (new UUID if none stored).
- `existing`: only send a session id if one was stored before.
@@ -156,6 +167,8 @@ load local files from plain paths (Claude CLI behavior).
## Inputs / outputs
- `output: "json"` (default) tries to parse JSON and extract text + session id.
- `output: "jsonl"` parses JSONL streams (Codex CLI `--json`) and extracts the
last agent message plus `thread_id` when present.
- `output: "text"` treats stdout as the final response.
Input modes:
@@ -175,17 +188,33 @@ Clawdbot ships a default for `claude-cli`:
- `systemPromptWhen: "first"`
- `sessionMode: "always"`
Clawdbot also ships a default for `codex-cli`:
- `command: "codex"`
- `args: ["exec","--json","--color","never","--sandbox","read-only","--skip-git-repo-check"]`
- `resumeArgs: ["exec","resume","{sessionId}","--color","never","--sandbox","read-only","--skip-git-repo-check"]`
- `output: "jsonl"`
- `resumeOutput: "text"`
- `modelArg: "--model"`
- `imageArg: "--image"`
- `sessionMode: "existing"`
Override only if needed (common: absolute `command` path).
## Limitations
- **No tools** (tool calls are disabled by design).
- **No Clawdbot tools** (the CLI backend never receives tool calls). Some CLIs
may still run their own agent tooling.
- **No streaming** (CLI output is collected then returned).
- **Structured outputs** depend on the CLIs JSON format.
- **Codex CLI sessions** resume via text output (no JSONL), which is less
structured than the initial `--json` run. Clawdbot sessions still work
normally.
## Troubleshooting
- **CLI not found**: set `command` to a full path.
- **Wrong model name**: use `modelAliases` to map `provider/model` → CLI model.
- **No session continuity**: ensure `sessionArg` is set and `sessionMode` is not `none`.
- **No session continuity**: ensure `sessionArg` is set and `sessionMode` is not
`none` (Codex CLI currently cannot resume with JSON output).
- **Images ignored**: set `imageArg` (and verify CLI supports file paths).

View File

@@ -175,12 +175,14 @@ CLAWDBOT_LIVE_TEST=1 CLAWDBOT_LIVE_SETUP_TOKEN=1 CLAWDBOT_LIVE_SETUP_TOKEN_PROFI
- Args: `["-p","--output-format","json","--dangerously-skip-permissions"]`
- Overrides (optional):
- `CLAWDBOT_LIVE_CLI_BACKEND_MODEL="claude-cli/claude-opus-4-5"`
- `CLAWDBOT_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.2-codex"`
- `CLAWDBOT_LIVE_CLI_BACKEND_COMMAND="/full/path/to/claude"`
- `CLAWDBOT_LIVE_CLI_BACKEND_ARGS='["-p","--output-format","json","--permission-mode","bypassPermissions"]'`
- `CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV='["ANTHROPIC_API_KEY","ANTHROPIC_API_KEY_OLD"]'`
- `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_PROBE=1` to send a real image attachment (paths are injected into the prompt).
- `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_ARG="--image"` to pass image file paths as CLI args instead of prompt injection.
- `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_MODE="repeat"` (or `"list"`) to control how image args are passed when `IMAGE_ARG` is set.
- `CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE=1` to send a second turn and validate resume flow.
- `CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG=0` to keep Claude CLI MCP config enabled (default disables MCP config with a temporary empty file).
Example:

View File

@@ -47,6 +47,38 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
serialize: true,
};
const DEFAULT_CODEX_BACKEND: CliBackendConfig = {
command: "codex",
args: [
"exec",
"--json",
"--color",
"never",
"--sandbox",
"read-only",
"--skip-git-repo-check",
],
resumeArgs: [
"exec",
"resume",
"{sessionId}",
"--color",
"never",
"--sandbox",
"read-only",
"--skip-git-repo-check",
],
output: "jsonl",
resumeOutput: "text",
input: "arg",
modelArg: "--model",
sessionIdFields: ["thread_id"],
sessionMode: "existing",
imageArg: "--image",
imageMode: "repeat",
serialize: true,
};
function normalizeBackendKey(key: string): string {
return normalizeProviderId(key);
}
@@ -76,11 +108,16 @@ function mergeBackendConfig(
new Set([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])]),
),
sessionIdFields: override.sessionIdFields ?? base.sessionIdFields,
sessionArgs: override.sessionArgs ?? base.sessionArgs,
resumeArgs: override.resumeArgs ?? base.resumeArgs,
};
}
export function resolveCliBackendIds(cfg?: ClawdbotConfig): Set<string> {
const ids = new Set<string>([normalizeBackendKey("claude-cli")]);
const ids = new Set<string>([
normalizeBackendKey("claude-cli"),
normalizeBackendKey("codex-cli"),
]);
const configured = cfg?.agents?.defaults?.cliBackends ?? {};
for (const key of Object.keys(configured)) {
ids.add(normalizeBackendKey(key));
@@ -102,6 +139,12 @@ export function resolveCliBackendConfig(
if (!command) return null;
return { id: normalized, config: { ...merged, command } };
}
if (normalized === "codex-cli") {
const merged = mergeBackendConfig(DEFAULT_CODEX_BACKEND, override);
const command = merged.command?.trim();
if (!command) return null;
return { id: normalized, config: { ...merged, command } };
}
if (!override) return null;
const command = override.command?.trim();

View File

@@ -178,7 +178,10 @@ function toUsage(raw: Record<string, unknown>): CliUsage | undefined {
: undefined;
const input = pick("input_tokens") ?? pick("inputTokens");
const output = pick("output_tokens") ?? pick("outputTokens");
const cacheRead = pick("cache_read_input_tokens") ?? pick("cacheRead");
const cacheRead =
pick("cache_read_input_tokens") ??
pick("cached_input_tokens") ??
pick("cacheRead");
const cacheWrite = pick("cache_write_input_tokens") ?? pick("cacheWrite");
const total = pick("total_tokens") ?? pick("total");
if (!input && !output && !cacheRead && !cacheWrite && !total)
@@ -246,6 +249,47 @@ function parseCliJson(
return { text: text.trim(), sessionId, usage };
}
function parseCliJsonl(
raw: string,
backend: CliBackendConfig,
): CliOutput | null {
const lines = raw
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) return null;
let sessionId: string | undefined;
let usage: CliUsage | undefined;
const texts: string[] = [];
for (const line of lines) {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
if (!isRecord(parsed)) continue;
if (!sessionId) sessionId = pickSessionId(parsed, backend);
if (!sessionId && typeof parsed.thread_id === "string") {
sessionId = parsed.thread_id.trim();
}
if (isRecord(parsed.usage)) {
usage = toUsage(parsed.usage) ?? usage;
}
const item = isRecord(parsed.item) ? parsed.item : null;
if (item && typeof item.text === "string") {
const type =
typeof item.type === "string" ? item.type.toLowerCase() : "";
if (!type || type.includes("message")) {
texts.push(item.text);
}
}
}
const text = texts.join("\n").trim();
if (!text) return null;
return { text, sessionId, usage };
}
function resolveSystemPromptUsage(params: {
backend: CliBackendConfig;
isNewSession: boolean;
@@ -328,21 +372,33 @@ async function writeCliImages(
function buildCliArgs(params: {
backend: CliBackendConfig;
baseArgs: string[];
modelId: string;
sessionId?: string;
systemPrompt?: string | null;
imagePaths?: string[];
promptArg?: string;
useResume: boolean;
}): string[] {
const args: string[] = [...(params.backend.args ?? [])];
if (params.backend.modelArg && params.modelId) {
const args: string[] = [...params.baseArgs];
if (!params.useResume && params.backend.modelArg && params.modelId) {
args.push(params.backend.modelArg, params.modelId);
}
if (params.systemPrompt && params.backend.systemPromptArg) {
if (
!params.useResume &&
params.systemPrompt &&
params.backend.systemPromptArg
) {
args.push(params.backend.systemPromptArg, params.systemPrompt);
}
if (params.sessionId && params.backend.sessionArg) {
args.push(params.backend.sessionArg, params.sessionId);
if (!params.useResume && params.sessionId) {
if (params.backend.sessionArgs && params.backend.sessionArgs.length > 0) {
for (const entry of params.backend.sessionArgs) {
args.push(entry.replaceAll("{sessionId}", params.sessionId));
}
} else if (params.backend.sessionArg) {
args.push(params.backend.sessionArg, params.sessionId);
}
}
if (params.imagePaths && params.imagePaths.length > 0) {
const mode = params.backend.imageMode ?? "repeat";
@@ -434,8 +490,19 @@ export async function runCliAgent(params: {
backend,
cliSessionId: params.cliSessionId,
});
const sessionIdSent =
backend.sessionArg && cliSessionIdToSend ? cliSessionIdToSend : undefined;
const useResume = Boolean(
params.cliSessionId &&
cliSessionIdToSend &&
backend.resumeArgs &&
backend.resumeArgs.length > 0,
);
const sessionIdSent = cliSessionIdToSend
? useResume ||
Boolean(backend.sessionArg) ||
Boolean(backend.sessionArgs?.length)
? cliSessionIdToSend
: undefined
: undefined;
const systemPromptArg = resolveSystemPromptUsage({
backend,
isNewSession: isNew,
@@ -459,13 +526,23 @@ export async function runCliAgent(params: {
prompt,
});
const stdinPayload = stdin ?? "";
const baseArgs = useResume
? (backend.resumeArgs ?? backend.args ?? [])
: (backend.args ?? []);
const resolvedArgs = useResume
? baseArgs.map((entry) =>
entry.replaceAll("{sessionId}", cliSessionIdToSend ?? ""),
)
: baseArgs;
const args = buildCliArgs({
backend,
baseArgs: resolvedArgs,
modelId: normalizedModel,
sessionId: cliSessionIdToSend,
systemPrompt: systemPromptArg,
imagePaths,
promptArg: argsPrompt,
useResume,
});
const serialize = backend.serialize ?? true;
@@ -556,9 +633,16 @@ export async function runCliAgent(params: {
});
}
if (backend.output === "text") {
const outputMode =
useResume ? backend.resumeOutput ?? backend.output : backend.output;
if (outputMode === "text") {
return { text: stdout, sessionId: undefined };
}
if (outputMode === "jsonl") {
const parsed = parseCliJsonl(stdout, backend);
return parsed ?? { text: stdout };
}
const parsed = parseCliJson(stdout, backend);
return parsed ?? { text: stdout };
@@ -572,7 +656,7 @@ export async function runCliAgent(params: {
meta: {
durationMs: Date.now() - started,
agentMeta: {
sessionId: output.sessionId ?? sessionIdSent ?? params.sessionId,
sessionId: output.sessionId ?? sessionIdSent,
provider: params.provider,
model: modelId,
usage: output.usage,

View File

@@ -31,6 +31,7 @@ export function normalizeProviderId(provider: string): string {
export function isCliProvider(provider: string, cfg?: ClawdbotConfig): boolean {
const normalized = normalizeProviderId(provider);
if (normalized === "claude-cli") return true;
if (normalized === "codex-cli") return true;
const backends = cfg?.agents?.defaults?.cliBackends ?? {};
return Object.keys(backends).some(
(key) => normalizeProviderId(key) === normalized,

View File

@@ -1365,7 +1365,9 @@ export type CliBackendConfig = {
/** Base args applied to every invocation. */
args?: string[];
/** Output parsing mode (default: json). */
output?: "json" | "text";
output?: "json" | "text" | "jsonl";
/** Output parsing mode when resuming a CLI session. */
resumeOutput?: "json" | "text" | "jsonl";
/** Prompt input mode (default: arg). */
input?: "arg" | "stdin";
/** Max prompt length for arg mode (if exceeded, stdin is used). */
@@ -1380,6 +1382,10 @@ export type CliBackendConfig = {
modelAliases?: Record<string, string>;
/** Flag used to pass session id (e.g. --session-id). */
sessionArg?: string;
/** Extra args used when resuming a session (use {sessionId} placeholder). */
sessionArgs?: string[];
/** Alternate args to use when resuming a session (use {sessionId} placeholder). */
resumeArgs?: string[];
/** When to pass session ids. */
sessionMode?: "always" | "existing" | "none";
/** JSON fields to read session id from (in order). */

View File

@@ -127,7 +127,12 @@ const HumanDelaySchema = z.object({
const CliBackendSchema = z.object({
command: z.string(),
args: z.array(z.string()).optional(),
output: z.union([z.literal("json"), z.literal("text")]).optional(),
output: z
.union([z.literal("json"), z.literal("text"), z.literal("jsonl")])
.optional(),
resumeOutput: z
.union([z.literal("json"), z.literal("text"), z.literal("jsonl")])
.optional(),
input: z.union([z.literal("arg"), z.literal("stdin")]).optional(),
maxPromptArgChars: z.number().int().positive().optional(),
env: z.record(z.string(), z.string()).optional(),
@@ -135,6 +140,8 @@ const CliBackendSchema = z.object({
modelArg: z.string().optional(),
modelAliases: z.record(z.string(), z.string()).optional(),
sessionArg: z.string().optional(),
sessionArgs: z.array(z.string()).optional(),
resumeArgs: z.array(z.string()).optional(),
sessionMode: z
.union([z.literal("always"), z.literal("existing"), z.literal("none")])
.optional(),

View File

@@ -14,15 +14,25 @@ import { startGatewayServer } from "./server.js";
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const CLI_LIVE = process.env.CLAWDBOT_LIVE_CLI_BACKEND === "1";
const CLI_IMAGE = process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_PROBE === "1";
const CLI_RESUME = process.env.CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE === "1";
const describeLive = LIVE && CLI_LIVE ? describe : describe.skip;
const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-5";
const DEFAULT_ARGS = [
const DEFAULT_CLAUDE_ARGS = [
"-p",
"--output-format",
"json",
"--dangerously-skip-permissions",
];
const DEFAULT_CODEX_ARGS = [
"exec",
"--json",
"--color",
"never",
"--sandbox",
"read-only",
"--skip-git-repo-check",
];
const DEFAULT_CLEAR_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"];
function randomImageProbeCode(len = 10): string {
@@ -213,25 +223,44 @@ describeLive("gateway live (cli backend)", () => {
const rawModel =
process.env.CLAWDBOT_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL;
const parsed = parseModelRef(rawModel, "claude-cli");
if (!parsed || parsed.provider !== "claude-cli") {
if (!parsed) {
throw new Error(
`CLAWDBOT_LIVE_CLI_BACKEND_MODEL must resolve to a claude-cli model. Got: ${rawModel}`,
`CLAWDBOT_LIVE_CLI_BACKEND_MODEL must resolve to a CLI backend model. Got: ${rawModel}`,
);
}
const modelKey = `${parsed.provider}/${parsed.model}`;
const providerId = parsed.provider;
const modelKey = `${providerId}/${parsed.model}`;
const providerDefaults =
providerId === "claude-cli"
? { command: "claude", args: DEFAULT_CLAUDE_ARGS }
: providerId === "codex-cli"
? { command: "codex", args: DEFAULT_CODEX_ARGS }
: null;
const cliCommand =
process.env.CLAWDBOT_LIVE_CLI_BACKEND_COMMAND ?? "claude";
process.env.CLAWDBOT_LIVE_CLI_BACKEND_COMMAND ??
providerDefaults?.command;
if (!cliCommand) {
throw new Error(
`CLAWDBOT_LIVE_CLI_BACKEND_COMMAND is required for provider "${providerId}".`,
);
}
const baseCliArgs =
parseJsonStringArray(
"CLAWDBOT_LIVE_CLI_BACKEND_ARGS",
process.env.CLAWDBOT_LIVE_CLI_BACKEND_ARGS,
) ?? DEFAULT_ARGS;
) ?? providerDefaults?.args;
if (!baseCliArgs || baseCliArgs.length === 0) {
throw new Error(
`CLAWDBOT_LIVE_CLI_BACKEND_ARGS is required for provider "${providerId}".`,
);
}
const cliClearEnv =
parseJsonStringArray(
"CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV",
process.env.CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV,
) ?? DEFAULT_CLEAR_ENV;
) ?? (providerId === "claude-cli" ? DEFAULT_CLEAR_ENV : []);
const cliImageArg =
process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_ARG?.trim() || undefined;
const cliImageMode = parseImageMode(
@@ -247,16 +276,17 @@ describeLive("gateway live (cli backend)", () => {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-live-cli-"),
);
const mcpConfigPath = path.join(tempDir, "claude-mcp.json");
await fs.writeFile(
mcpConfigPath,
`${JSON.stringify({ mcpServers: {} }, null, 2)}\n`,
);
const disableMcpConfig =
process.env.CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0";
const cliArgs = disableMcpConfig
? withMcpConfigOverrides(baseCliArgs, mcpConfigPath)
: baseCliArgs;
let cliArgs = baseCliArgs;
if (providerId === "claude-cli" && disableMcpConfig) {
const mcpConfigPath = path.join(tempDir, "claude-mcp.json");
await fs.writeFile(
mcpConfigPath,
`${JSON.stringify({ mcpServers: {} }, null, 2)}\n`,
);
cliArgs = withMcpConfigOverrides(baseCliArgs, mcpConfigPath);
}
const cfg = loadConfig();
const existingBackends = cfg.agents?.defaults?.cliBackends ?? {};
@@ -272,10 +302,10 @@ describeLive("gateway live (cli backend)", () => {
},
cliBackends: {
...existingBackends,
"claude-cli": {
[providerId]: {
command: cliCommand,
args: cliArgs,
clearEnv: cliClearEnv,
clearEnv: cliClearEnv.length > 0 ? cliClearEnv : undefined,
systemPromptWhen: "never",
...(cliImageArg
? { imageArg: cliImageArg, imageMode: cliImageMode }
@@ -306,12 +336,16 @@ describeLive("gateway live (cli backend)", () => {
const sessionKey = "agent:dev:live-cli-backend";
const runId = randomUUID();
const nonce = randomBytes(3).toString("hex").toUpperCase();
const message =
providerId === "codex-cli"
? `Please include the token CLI-BACKEND-${nonce} in your reply.`
: `Reply with exactly: CLI backend OK ${nonce}.`;
const payload = await client.request<Record<string, unknown>>(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runId}`,
message: `Reply with exactly: CLI backend OK ${nonce}.`,
message,
deliver: false,
},
{ expectFinal: true },
@@ -320,7 +354,43 @@ describeLive("gateway live (cli backend)", () => {
throw new Error(`agent status=${String(payload?.status)}`);
}
const text = extractPayloadText(payload?.result);
expect(text).toContain(`CLI backend OK ${nonce}.`);
if (providerId === "codex-cli") {
expect(text).toContain(`CLI-BACKEND-${nonce}`);
} else {
expect(text).toContain(`CLI backend OK ${nonce}.`);
}
if (CLI_RESUME) {
const runIdResume = randomUUID();
const resumeNonce = randomBytes(3).toString("hex").toUpperCase();
const resumeMessage =
providerId === "codex-cli"
? `Please include the token CLI-RESUME-${resumeNonce} in your reply.`
: `Reply with exactly: CLI backend RESUME OK ${resumeNonce}.`;
const resumePayload = await client.request<Record<string, unknown>>(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runIdResume}`,
message: resumeMessage,
deliver: false,
},
{ expectFinal: true },
);
if (resumePayload?.status !== "ok") {
throw new Error(
`resume status=${String(resumePayload?.status)}`,
);
}
const resumeText = extractPayloadText(resumePayload?.result);
if (providerId === "codex-cli") {
expect(resumeText).toContain(`CLI-RESUME-${resumeNonce}`);
} else {
expect(resumeText).toContain(
`CLI backend RESUME OK ${resumeNonce}.`,
);
}
}
if (CLI_IMAGE) {
const imageCode = randomImageProbeCode(10);