# How to Make a Control Plane for Coding Agents in 2026

> Source: <https://abhishek.it/blog/how-to-make-a-control-plane-for-coding-agents-2026>
> Published: 2026-09-08 12:50:05+00:00

[Back to writing](/#writing)

# How to Make a Control Plane for Coding Agents in 2026

Claude Code, Codex, OpenCode and Pi all talk differently. Claude: Anthropic's SDK. Codex: a small server you send JSON to. OpenCode: an HTTP server with an event stream. Pi: a terminal. Every product that puts them in one window has to do the same three jobs, and there are only three ways to do the first one.

[Written by Abhishek Gahlot](/about)

and how others are making it

## Every tool does the same three things

### Talk to each agent

One adapter per agent. Send the prompt, receive events, answer "can I run this?"

### Translate into one vocabulary

Whatever any agent sends becomes the same few words: said text, thinking, tool started, tool finished, needs permission, plan changed, turn done.

### Draw it

Deltas become one message. Tool started plus tool finished become one row. Rows group into a work block.

Job 1 is where the products differ. Three ways to speak to an agent, and each one caps what the screen can ever show. The quickest way to see it: the same edit, one line in `src/billing.ts`, as it arrives each way.

## Speaking through a terminal

PTY
A PTY is a pseudo-terminal. A fake terminal a program runs inside, so the program thinks a person is typing. The product launches the ordinary CLI, the one you'd run yourself, inside a PTY, types the prompt in, and reads the text that comes out. A person at a keyboard, except the person is a program.

``` js
● Update(src/billing.ts)
     12 -  const rate = 0.2
     12 +  const rate = 0.25
no prompt: --dangerously-skip-permissions
```

Text, not data. The path is a regex over scrollback. "Done" comes from a hook installed into the agent's config. Approval would mean typing y, so the bypass flag is on.

**Who:** Orca and Superset, by default. Both keep a real driver behind a flag.

## Speaking through ACP

Agent Client Protocol
ACP is Zed's protocol. JSON-RPC over stdio, one wire format for start a session, send a prompt, get updates, answer permissions. Gemini, OpenCode, Cursor CLI, Copilot CLI, Kiro, Factory Droid, Devin's CLI and DeepSeek's harness speak it natively. Claude Code and Codex don't. They get there through adapters, `claude-agent-acp` and `codex-acp`, separate processes that run the native driver underneath.

```
session/update  tool_call
  call_7  kind: edit  status: pending
  locations: [ src/billing.ts ]
  content: [ diff … ]   if the adapter fills it
session/request_permission → allow_once
session/update  tool_call_update  call_7  completed
no usage · no subagent kind · nothing in between
```

Data, and a permission you can answer. For Claude and Codex it comes through an adapter process: the session lives there, so your restart has nothing to reattach to; it applies its own approval policy; and the wire never says which driver or version is behind it.

**Who:** Emdash, by default. Paseo and Waku for agents that speak nothing else. Editors, where it started.

## Speaking the native language

SDK · app-server · HTTP · RPC
Each agent has a machine interface its own vendor's products use. Claude Code: the Agent SDK runs the loop in your process, hands you every event, asks you through a callback before each tool. Codex: the app-server, a JSON-RPC process with thread and turn methods, item lifecycle notifications, approval requests per kind. OpenCode: a server with sessions, an SSE stream, permissions, questions. Pi: an RPC mode over its terminal with prompt, steer, follow-up, abort, fork.

```
canUseTool("Edit", input) → allow
tool_use  Edit  toolu_01
  file_path:  src/billing.ts
  old_string: const rate = 0.2
  new_string: const rate = 0.25
tool_result  toolu_01  updated
result  session_id: ses_9f2  cost_usd: 0.0123
```

Everything, in the agent's own words: path and both strings, the question before the tool runs, a session id to resume from, cost per turn, subagent calls tagged with their parent. Codex, OpenCode and Pi give the same in their own shapes. The price is one adapter per agent, pinned.

**Who:** Paseo, Waku, T3 Code and UseAgent, for all four agents. Orca and Superset behind a flag.

### What the screen can draw from each

| Capability | TerminalOrca, Superset | ACPEmdash; adapters for Claude and Codex | NativePaseo, Waku, T3, UseAgent | 
|---|---|---|---|
| The edited file's path | scrape the scrollback | yes | yes | 
| The diff | scrape | if the adapter fills it | yes, old and new text | 
| Ask before the tool runs | no, bypass flag on | yes, adapter's policy | yes, your code answers | 
| Progress while a command runs | text scrolls | no, pending then completed | yes, deltas | 
| Resume after your process restarts | no | only if the agent implements session/load | yes, session id | 
| Token cost per turn | no | no in version 1 | yes | 
| A subagent shown as a child | no | no, a tool of kind "other" | yes, parent tool id | 
| Know which driver ran | yes, it is the CLI | no | yes | 
| Adapters to maintain | 0 | 1 | one per agent | 

Scroll to compare → Row labels stay in view.

The middle column is the one that surprises people. ACP looks like data and mostly is. What it costs is the three rows a control plane exists for: resume, approvals that mean what the agent meant, and knowing what is running. For an agent that speaks nothing else, ACP is the right door. For Claude and Codex it is a second door to an agent you can already reach.

## The Rust way: Waku

Every other product here is TypeScript. So are the agents' own client libraries: the Claude Agent SDK is a Node package, OpenCode's SDK is TypeScript, T3's Codex driver is a TypeScript port of the app-server schema. Waku is Rust end to end. A daemon, a GPUI desktop app, a protocol crate. That one choice changes how all three jobs get done.

Every other product here is TypeScript, like the agents' own client libraries. Waku is Rust end to end: a daemon, a GPUI desktop app, a protocol crate. So its vocabulary is a Rust enum, not a schema. Every driver must produce it and every consumer must match it exhaustively; add a variant and the daemon and the app fail to compile until both handle it.

The protocol types carry a `TS` derive. ts-rs generates the TypeScript from them into the client package, and the web and mobile apps import it. Daemon, native app, browser and phone share one definition of a session, an activity, a permission. Codex's app-server protocol is Rust with ts-rs bindings of its own, so Waku speaks it type to type; Claude has no Rust library, so Waku speaks its stream-json wire directly, no Node sidecar.

The screen is the point: "staying smooth under a long transcript on a high-refresh display is the point of being native." A virtualized GPUI list, drivers feeding a channel the frame loop drains. What it leaves out is everything multi-user: one token, SQLite and git refs as the record, no organization.

## The vocabulary they share

Whichever way an agent is spoken to, what comes out is folded into the same nouns. Read across a row and you are reading translations.

| Event family | ACPthe wire spec, session/update | T3 Codeactivity kinds at ingestion | WakuDriverEvent, Rust | Paseotimeline items | Emdashparser output from ACP | UseAgentcanonical events | 
|---|---|---|---|---|---|---|
| Assistant text | `agent_message_chunk` | a provider event, not an activity | `TextDelta` | `assistant_message` | `message`` text` | `message.started``.delta``.completed` | 
| Reasoning | `agent_thought_chunk` | same, via `textDelta` | `ReasoningDelta` | `reasoning` | `thinking` | `reasoning.delta``.completed` | 
| Tool lifecycle | `tool_call`` tool_call_update` | `tool.started``.updated``.progress``.completed``.denied` | `Activity`` RichActivity` | `tool_call` with a kind | `tool_call`` tool_update` , then typed calls | `tool.started``.progress``.completed` | 
| Permission | `session/request_permission` | `approval.requested``.resolved` | `Permission` | `permission_requested``_resolved` | answered by the client, not folded | `approval.requested``.resolved` | 
| Question | `elicitation/create` | `user-input.requested``.resolved` | `UserInputRequested` | `attention_required` | `select` | `question.requested``.resolved` | 
| Plan | `plan` | `turn.plan.updated` | `ActivityKind::Plan` | `plan`` todo` | `plan` | `plan.updated` | 
| Subagent | a tool call of kind other | `task.started``.updated``.progress``.completed` | `BackgroundWork` | `sub_agent` | `subagent_update` | `child.started``.updated``.completed` | 
| Turn | prompt request, stop reason | a thread event | `TurnStarted`` TurnParked` | `turn_started``_completed``_failed` | `turn_end` | `turn.started`` turn.completed` | 
| Usage | none in v1 | `context-window.updated` | `UsageUpdated` | `usage_updated` | `usage` | `usage.updated` | 
| Trouble | JSON-RPC error | `runtime.error``.warning` | event journal | `error`` warning` | `ignored` | `harness.error``.warning` | 

Scroll to compare → Row labels stay in view.

Tool calls fall into the same eight families everywhere: read, edit or write, execute, search, fetch, plan, subagent, other. ACP names them in the spec; the others classify by the tool's name.

## Keeping the record

Most tools translate on the way in and keep only the translation. On one laptop that's fine, the agent's own transcript on disk is the raw copy. A hosted product doesn't have that transcript. The sandbox that wrote it is gone. So it has to keep its own.

Most tools translate on the way in and keep only the translation. On one laptop that's fine, the agent's own transcript on disk is the raw copy. A hosted product doesn't have that transcript. The sandbox that wrote it is gone. So it keeps its own: the event exactly as the agent sent it, then the vocabulary derived from that. The translator is the piece most likely to be wrong, because the agents change weekly, and with the raw copy kept a fix is a re-run over history instead of a permanent hole.

Emdash keeps nothing and re-asks the agent. Paseo keeps rows in memory. Waku keeps SQLite and a resume cursor. T3 keeps an event store. A hosted control plane keeps the raw frame and the translation, sealed per run, scoped to an organization.

## Seven products, side by side

Read each column top to bottom. The driver row decides everything under it.

| Compare by | OrcaStably AI, YC · MIT | SupersetSuperset Inc. · Elastic 2.0 | EmdashGeneral Action, YC · Apache-2.0 | Paseoone maintainer · Apache-2.0 | Wakuegoist · GPL-3.0 · Rust | T3 Coderuntime, Effect | UseAgentAGPL core + pro | 
|---|---|---|---|---|---|---|---|
| Spoken tojob 1 | PTY 36 agents, hooks for status | PTY 19 agents, hooks for status | ACP 25 agents, official adapters for Claude and Codex | native 4 agents; ACP for about 30 more | native 7 agents; ACP for 4 more | native 3 agents; ACP for 3 more | native 4 agents, via T3 and an RPC bridge for Pi | 
| Translatedjob 2 | none by default | chat mode only, off | parsed from ACP into typed tool calls | own timeline items, in memory | a Rust enum | activity kinds | raw frame, then canonical events | 
| Kept where | scrollback checkpoints | 64 KB ring buffer | re-asked from the agent on load | in memory; provider history | SQLite + git refs | event store | Postgres, org-scoped, sealed | 
| Drawnjob 3 | xterm, diff view | terminal, diff, PRs | transcript, tool groups | desktop, mobile, voice | timeline with rewind | work groups | one timeline for all four | 
| Whose record | one person | one person; org in the cloud API | one person | one person | one person | one person | an organization | 

Scroll to compare → Row labels stay in view.

## About the author

I build **UseAgent**, the hosted one in the last column: the four agents in cloud workstations, one timeline, scoped to a company. It runs on T3 Code's runtime and grammar, because T3 already spoke the native protocols and its event core is the right seam. UseAgent keeps the raw frame T3 hands over and translates it again for the record.

## Sources

- **Agent Client Protocol** ·[agentclientprotocol.com](https://agentclientprotocol.com) ·[github](https://github.com/agentclientprotocol/agent-client-protocol)
- **Claude Code** ·[code.claude.com/docs](https://code.claude.com/docs) ·[github](https://github.com/anthropics/claude-agent-sdk-typescript)
- **Codex** ·[learn.chatgpt.com/docs](https://learn.chatgpt.com/docs) ·[github](https://github.com/openai/codex)
- **OpenCode** ·[opencode.ai/docs](https://opencode.ai/docs) ·[github](https://github.com/sst/opencode)
- **Pi** ·[github](https://github.com/badlogic/pi-mono)
- **Cursor** ·[cursor.com/docs/cli](https://cursor.com/docs/cli)
- **Orca** ·[onorca.dev](https://onorca.dev) ·[github](https://github.com/stablyai/orca)
- **Superset** ·[superset.sh](https://superset.sh) ·[github](https://github.com/superset-sh/superset)
- **Emdash** ·[emdash.com](https://emdash.com) ·[github](https://github.com/generalaction/emdash)
- **Paseo** ·[paseo.sh](https://paseo.sh) ·[github](https://github.com/getpaseo/paseo)
- **Waku** ·[waku.sh](https://waku.sh) ·[github](https://github.com/egoist/waku)
- **T3 Code** ·[github](https://github.com/pingdotgg/t3code)
- **UseAgent** ·[useagent.org](https://useagent.org) ·[github](https://github.com/useagenthq/useagent)
