How lemon-code chooses between an installed agent CLI and an HTTP provider, and why one must never silently become the other
Published: October 21, 2026
Reading time: 8 min
Series: lemon-code in 30 days, part 2 of 13
For the first few months, lemon-code's main agent could only talk to HTTP APIs. External CLIs (Claude Code, Codex, Gemini CLI, OpenCode) existed only as subagents you had to call explicitly.
That was absurd. I was logged into Claude Code and Codex with active subscriptions, and lemon-code still asked for an API key before it would answer. It contradicted the project's local-first premise.
In July I wrote the fix down as an ADR: in automatic mode, lemon-code prefers an eligible CLI when CLI-first is enabled; users can also choose a runtime or provider explicitly. This post covers how that works and where the traps are.
Think of two independent paths to the same result. A CLI uses its own binary and authentication; an HTTP provider uses a separate credential. Dispatch chooses one explicit target for each turn.
The change starts with a type. The router used to return just provider/model. Now it returns a target of one of two kinds:
// dispatch target (rewritten and simplified)
type RuntimeKind = "claude" | "codex" | "gemini" | "agy" | "opencode" | "copilot" | "grok" | "cursor";
type DispatchTarget =
| { type: "runtime"; runtime: RuntimeKind; model?: string }
| { type: "provider"; provider: string; model: string };
The code that runs a turn no longer has to guess. A runtime target calls the CLI; a provider target calls the API. Never both in the same turn.
--runtime, then --provider, then automatic CLI-first when enabled and an eligible runtime is available, and only then HTTP-provider scoring.codex fails, you get a Codex error, not a hidden call to the OpenAI API.It's tempting to treat codex as an alias for openai, or claude for anthropic. The ADR forbids it explicitly.
| Runtime (CLI) | Binary | Provider it does not imply |
|---|---|---|
claude | claude | anthropic |
codex | codex | openai |
gemini | gemini | google |
agy | agy | google |
opencode | opencode | OpenCode HTTP API |
The reason is to keep authentication and target separate. A CLI uses its own binary and login. An HTTP API uses a key. If code confuses the two, a CLI login failure can trigger an HTTP request without you noticing.
Free and paid products to turn ideas into a foundation you can actually ship.
13 products availableContinue exploring similar topics
For HTTP-provider routes, lemon-code records score factors. This teaching example shows how to read the sum and distinguish it from the JEV layer.
lemon-code has six ADRs numbered 0049, all created on the same day. That mistake teaches more about project memory with multiple agents than any well-numbered ADR.
My agent's telemetry ships disabled, points at localhost by default and excludes prompts. It still shows duration, fallbacks and routing decisions.
A practical checklist to validate security, cost, evals, and observability before you ship your AI app to production.
Production-tested templates trusted by developers. Save weeks of setup on your next project.
// dispatch precedence (illustrative)
function resolveTarget(opts: {
runtime?: RuntimeKind;
provider?: string;
installed: RuntimeKind[]; // binaries found on PATH
preferCli: boolean; // default: true
scoreApis: () => DispatchTarget;
}): DispatchTarget {
if (opts.runtime && opts.provider) throw new Error("use --runtime OR --provider");
if (opts.runtime) return { type: "runtime", runtime: opts.runtime };
if (opts.provider) return { type: "provider", provider: opts.provider, model: "default" };
if (opts.preferCli && opts.installed.length > 0) {
return { type: "runtime", runtime: opts.installed[0] };
}
return opts.scoreApis(); // fallback: API scoring and readiness
}
--runtime <name> picks only that CLI. A missing binary or bad login is an actionable runtime error. It never falls through to an API.--provider <name> picks only that API and skips CLI discovery.--runtime and --provider are mutually exclusive. Passing both is an error on purpose.
In automatic mode, lemon-code only checks that the binary exists on PATH. It doesn't try to open a session or test remote auth on every turn, which keeps dispatch fast and side-effect free. Login diagnostics live in an explicit command (lc doctor). Separating discovery from probing keeps the hot path cheap.
Every decision goes into the SQLite dispatch history with the target type (runtime:codex, say) and the reason. What does not go in: environment variables, keys, headers, cookies or unbounded CLI output. Subprocess output is truncated and scrubbed before logging.
--help and each CLI gets a contract test before it's enabled.--runtime codex without a login has to say "log in to Codex", not "set OPENAI_API_KEY". That's what makes people trust the rule.The ADR's acceptance criteria work as a checklist for any orchestrator:
--runtime X runs only CLI X.--provider Y runs only API Y.Copy the last one first. It catches the whole class of silent-fallback bugs.
Using eligible CLIs first simplifies dispatch. The bigger gain was clarity: every turn has one target, and that target doesn't change behind your back. Dispatch considers HTTP providers when CLI-first is disabled or no eligible runtime is selected; --provider remains available for an explicit choice.
Friday's post moves up a layer: how lemon-code builds its terminal UI with React through Ink, running on Bun.
If you're designing an agent orchestrator and want target selection, auth and routing separated from day one, that's one of the areas where I help teams at LEMON.DEV.