# Lessons from putting 14 coding-agent CLIs behind one interface

> Source: <https://dev.to/tesseract_app/lessons-from-putting-14-coding-agent-clis-behind-one-interface-gch>
> Published: 2026-11-06 19:18:21+00:00

We build a desktop app where one chat can be driven by any of 14 agent CLIs: Claude Code, Codex, OpenCode, Gemini

CLI, Kimi Code, Qwen Code, MiniMax Code, CodeBuddy, Grok Build, Cursor, Hermes Agent, OpenClaw, NanoClaw and

Antigravity CLI. You can switch the framework partway through a conversation and keep going. Getting there taught us

more about the differences between these tools than about what they share. Here is what held up.

Twelve of the fourteen speak the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), so they share one

backend. Claude Code runs through its SDK, in-process. Codex runs through `codex app-server`, its own JSON-RPC

server, which has a lovely property: `codex app-server generate-json-schema` prints its entire interface (at 0.149:

83 client methods, 10 server requests, 65 notifications). We wrote that backend against the generated schema

instead of guessing.

Why not put everything on ACP? Because a shared protocol only covers what everyone has. Route Claude Code through it

and constrained structured output becomes "ask for JSON and retry until it validates". Route Codex through it and a

real compaction call becomes a slash command. Our rule: **if standardising costs the strongest framework a capability it has today, the layer is wrong.** The layer should describe the differences, not remove them.

Every framework is one object behind one interface: eight required operations (`sendMessage`, `interrupt`,

`listSessions`, `getMessages`, `setConfig`, `reclaim`, plus `id` and `capabilities`) and optional ones like

`forkSession` or `respondToPermission`.

Each optional method is tied to one of 32 capability flags, and a test checks the pairing in **both** directions:

declared means implemented, and undeclared means absent. The second direction is the one people skip, and it's what

makes "what the app believes a framework can do" equal "what it does". The catalog entry is typed so every flag

needs an explicit answer — add a 33rd flag and the build fails until all 14 frameworks answer it.

A tick is a claim about *our* implementation, so we also record how each capability is reached. For Claude Code all

32 are native. For Gemini CLI 14 are native, 8 are the app's by design (things no framework will ship, like a

scheduler), and 10 are stand-ins until the framework ships its own — each stand-in records where to look for the

real one.

We tested this live: 22 skills, 14 requests (10 that need a skill, 4 that must not load one), in English, Romanian

and Russian, on five framework/model pairs — 630 runs. With search only, the right skill loaded 47–63% of the time.

With the skill index listed in the prompt: 97–100%, and no wrong skill in any of the 180 runs that should have loaded

none.

The instructions, skill index and tool index are built once when the session starts and never rewritten. A prefix

that never changes gets cached: on Claude Code, turn one wrote 45,021 tokens to the prompt cache; turn two read all

45,021 back and wrote 75 new ones. If something changes mid-session (a skill installed), the next turn carries a

one-line note instead of a rewritten prompt.

ACP has no method for subagents, background tasks or forms; other protocols have different gaps. Building each

capability once per protocol means 14 implementations of everything. Instead the app runs a local

[MCP](https://modelcontextprotocol.io) server and every framework connects to it. A tool is written once — name,

description, schema, handler — and because the handler runs in the app's process, it can open a form and wait for

the answer, or start a second session as a subagent, with no protocol support at all.

The catch is naming. The same tool reaches each model under a different name, with different limits:

| Framework | `skill_load` as the model sees it | Limit that bites | 
|---|---|---|
| Claude Code | `mcp__tesseract__skill_load` | descriptions cut at 2,048 chars | 
| Codex | `mcp__tesseract__` namespace +`skill_load` | names `[A-Za-z0-9_]` only; schemas over 5,000 bytes shrunk | 
| Gemini CLI | `mcp_tesseract_skill_load` | names over 63 chars mangled; `$schema` stripped | 
| OpenCode | `tesseract_skill_load` | forces `additionalProperties: false` | 

So every tool meets the strictest common standard at build time: a lower-case `area_verb` name of at most 40

characters, a description of at most 2,048, a schema of at most 5 KB with no root-level combinators.

Asking OpenCode for an image with every tool handed over directly: 82 tools, about 43,600 tokens on every model call.

With a small always-loaded core (`skill_find`, `skill_load`, `tool_find`, `tool_call`, `tool_call_readonly`,

`ask_user`, `plan_update`, `plan_propose`) and everything else behind `tool_find`: 19 tools, about 8,600 tokens, and

the picture still arrived.

Two dispatchers, not one, because frameworks run MCP calls without a read-only hint one at a time: three 4-second

tools took 12 s without the hint and 4 s with it. But one dispatcher marked read-only would let a framework

auto-approve tools that change things — so read-only tools go through `tool_call_readonly`, which refuses anything

off its allowlist.

Make the dispatcher forgiving: accept `tool`/` args` because that's what models send, parse `args` if it arrives as a

JSON string, and answer a wrong shape with the schema. Across five framework/model pairs and 166 real tool

definitions, the right tool with valid arguments came back 76 times out of 80.

We replace planning, questions, subagents, skills and tool search on every framework — but not the shell and the file

editor. A harness is tuned with its model. Terminal-Bench 2.0 ([arXiv 2601.11868](https://arxiv.org/abs/2601.11868))

ran the same models in their vendors' harnesses and in a neutral one: one scored 62.9% in its own and 54.0% in the

neutral one; another went the other way, 52.1% vs 57.8%. The edit dialect a model was trained on is worth more than

identical tooling.

The full write-up, with the event mapping and how history moves between frameworks, is on our blog:

[https://tesseract.dev/blog/one-chat-fourteen-agent-frameworks/?utm_source=27a2vo](https://tesseract.dev/blog/one-chat-fourteen-agent-frameworks/?utm_source=27a2vo)
