Self-Describing IPC Channels: Teaching the Dev Harness to Explain Itself
We added descriptions, categories, and argument hints to 38 IPC channels — so AI agents stop guessing which endpoint to call.
Self-Describing IPC Channels: Teaching the Dev Harness to Explain Itself
In a previous post, we described how Callipso's dev harness auto-exposes 500+ IPC channels as HTTP endpoints on port 3010. An AI agent can call GET /ipc/channels and get a complete list of everything the app can do.
There was a problem with that list. It looked like this:
{
"channel": "send-text-to-terminal-by-id",
"type": "invoke",
"hasHandler": true,
"stats": { "calls": 47, "errors": 0, "p50Ms": 12 }
}
Name, type, handler status, call stats. No description. No argument hints. No category. The agent had to guess what send-text-to-terminal-by-id does versus send-text-to-terminal versus store:sendText versus submit-to-terminal-by-id. Four channels that sound similar, with different interfaces and different behaviors.
This caused real, repeated mistakes. The agent would use the index-based send-text-to-terminal when it needed the UUID-based send-text-to-terminal-by-id. It would call store:sendText (a low-level store method) instead of the proper terminal operation. Every time, a human had to correct it. Every correction was the same correction — "use the UUID-based one."
So we made the channels describe themselves.
What Changed
GET /ipc/channels now returns enriched metadata for the most-used channels:
{
"channel": "send-text-to-terminal-by-id",
"type": "invoke",
"hasHandler": true,
"stats": { "calls": 47, "errors": 0, "p50Ms": 12 },
"description": "Send text to terminal by UUID. Preferred method for sending text.",
"category": "terminal",
"args": "{terminalId: string, text: string, silent?: boolean, submit?: boolean}"
}
Three new fields: description, category, and args. The description says what the channel does. The category groups it with related channels. The args field shows exactly what to pass. Some channels also include an example field with a ready-to-run curl command.
Channels without metadata still appear in the response — they just lack the extra fields. Fully backward-compatible.
Filtering
The endpoint now accepts query parameters that let an agent narrow down to exactly what it needs:
# Only terminal-related channels (20 results instead of 534)
curl 'http://localhost:3010/ipc/channels?category=terminal'
# Search by keyword across name and description
curl 'http://localhost:3010/ipc/channels?search=send+text'
# Only channels that have descriptions
curl 'http://localhost:3010/ipc/channels?described=true'
The category filter uses the same HandlerCategory type that already exists in our handler registry system: terminal, voice, system, settings, archive, stt, window, and others. The search filter matches all space-separated terms against both channel name and description, case-insensitive.
The response summary now includes a described count alongside the existing totals:
{
"count": 534,
"described": 38,
"invoke": 287,
"send": 143,
"receive": 104,
"channels": [...]
}
The Metadata Map
The descriptions live in a single file: src/ipc/channelMetadata.ts. It is a static Record<string, ChannelMeta> with entries for the 38 most-used channels, grouped by category:
| Category | Channels | Examples |
|---|---|---|
| terminal | 27 | send-text-to-terminal-by-id, create-claude-terminal, ghostty-benchmark |
| voice | 2 | create-claude-terminal-voice, set-voice-routing-destination |
| system | 5 | get-app-version, get-system-diagnosis, get-memory-stats |
| settings | 2 | get-log-level, set-log-level |
| archive | 2 | archive-list-sessions, archive-search |
Each entry has a plain-English description and a TypeScript-style args hint. The descriptions are written for AI consumption — concise, precise, and explicit about which channel to prefer when alternatives exist:
'store:sendText': {
description: 'Send text to terminal by index. Prefer send-text-to-terminal-by-id instead.',
category: 'terminal',
args: '{index: number, text: string}',
},
That "Prefer send-text-to-terminal-by-id instead" is the key sentence. The agent reads it, follows the recommendation, and the human never has to correct the same mistake again.
Why a Static Map Instead of Auto-Generation
We considered three approaches:
- Auto-generate from handler code — Parse JSDoc comments or function signatures. Fragile, would miss the "prefer X over Y" guidance that matters most.
- Require all handlers to use
defineHandler()— Our registry system already supports descriptions and Zod schemas, but only 7 of 500+ handlers use it. Forcing migration would be a multi-week project with no user-facing value. - Static map for the top 30-40 channels — Write descriptions by hand for the channels that agents actually use. Ship in an afternoon.
We chose option 3. The static map covers the channels that cause 95% of the confusion. As handlers gradually adopt defineHandler() over time, their metadata will flow automatically and the static entries can be removed. The two systems coexist without conflict.
This is a deliberate choice to optimize for immediate impact over architectural purity. A perfect system where every channel self-describes through Zod schemas would be elegant. A working system where the 38 most-confused channels have clear descriptions is useful today.
Implementation
Three files changed. No handler migration. No new dependencies.
src/ipc/channelMetadata.ts (new, ~230 lines) — The static metadata map. Imports HandlerCategory from the existing registry type system so categories stay consistent.
src/testing/ipcProxy.ts (~25 lines added) — The handleIpcChannels() function now accepts optional query params, merges metadata from the static map into each channel object, and applies category/search/described filters before returning.
src/testingServer.ts (~5 lines changed) — The route matcher for /ipc/channels now accepts query strings. Parses them with the same new URL(url, 'http://localhost') pattern used by /dev/logs and /dev/ui-anomalies.
The merge logic is a single spread per channel:
const meta = CHANNEL_METADATA[ch];
return {
channel: ch,
type: 'invoke',
hasHandler: tracked.has(ch),
stats: allStats.handlers[ch] || null,
...(meta && {
description: meta.description,
category: meta.category,
args: meta.args,
...(meta.example && { example: meta.example })
})
};
If metadata exists for a channel, it is spread into the response object. If not, the channel appears as before. No conditional logic in the handler code. No schema changes. The response is a superset of the old format.
The Disambiguation Problem
The deeper issue this solves is not missing documentation — it is channel disambiguation. Callipso has groups of channels that do similar things through different interfaces:
send-text-to-terminal (by index)
send-text-to-terminal-by-id (by UUID) ← preferred
store:sendText (low-level store method)
submit-to-terminal (by index, always submits)
submit-to-terminal-by-id (by UUID, always submits)
Five channels for "send text to a terminal." An AI agent scanning the channel list has no way to know which one to use. The index-based channels exist for backward compatibility with the overlay UI. The UUID-based channels are what external callers should use. The store method is an internal API that happens to be exposed.
Without descriptions, the agent picks one at random — usually the shortest name, which is usually wrong. With descriptions, the agent reads "Prefer send-text-to-terminal-by-id instead" and makes the right choice on the first try.
The same pattern applies to terminal creation (store:createTerminal vs create-claude-terminal vs create-empty-terminal), session stopping (stop-claude-session vs stop-claude-session-by-id), and terminal closing (close-terminal vs close-terminal-by-id vs store:closeTerminal).
Toward MCP-Style Discovery
This change moves the dev harness closer to what the Model Context Protocol calls tool discovery. MCP's tools/list returns a name, description, and input schema for each available tool. Our /ipc/channels?described=true now returns a name, description, category, and args hint for each described channel.
The gap is schema formality. MCP uses JSON Schema for input validation. We use plain-text args hints like {terminalId: string, text: string}. For our use case — Claude Code agents hitting curl endpoints — the plain-text hint is more readable and equally actionable. If we add an MCP server layer in the future, the static metadata map becomes the source of truth for tool descriptions, and the args hints can be promoted to proper schemas.
What We Learned
-
Discovery without description is insufficient. Listing 500 channel names is not tool discovery. Tool discovery requires knowing what each tool does, what it expects, and when to use it over alternatives. A name like
send-text-to-terminal-by-idis suggestive but not definitive. -
Disambiguation guidance is the highest-value metadata. The most useful descriptions are not "sends text to a terminal" — the agent can guess that from the name. The most useful descriptions are "Prefer X over Y" and "Low-level, use Z instead." Steering the agent away from wrong choices matters more than explaining right choices.
-
Static maps beat perfect systems that do not ship. We could have spent weeks migrating 500 handlers to
defineHandler()with Zod schemas. Instead, we wrote 38 entries in a static map and shipped in an afternoon. The 38 entries cover the channels that actually cause confusion. The other 496 channels are rarely called directly by agents, and their names are self-explanatory enough. -
Category filtering changes agent behavior. An agent that sees 534 channels will often scroll past the right one. An agent that queries
?category=terminalsees 27 channels and picks correctly. Reducing the search space matters as much as improving the descriptions.
The dev harness was already the most important piece of infrastructure in our AI-first workflow. Making it self-describing means agents spend less time guessing and more time building. The human corrects fewer mistakes. The loop tightens.
Thirty-eight descriptions. Three files. One afternoon. That is the kind of leverage that makes AI-assisted development work.