One app, four agent protocols

How the BeyDock sidecar routes commands to Claude, Codex, Antigravity and OpenCode, translates their events into one shape, and switches off what a provider cannot do.

BeyDock4 min read

BeyDock runs four coding agents, and they have almost nothing in common on the wire. Claude comes through the Agent SDK, Codex through codex app-server, Antigravity through the agy CLI in stream-json mode, and OpenCode through the Agent Client Protocol. This build log covers how we keep the app itself from knowing about any of that.

A sidecar per host, a provider per protocol

The SwiftUI app does not talk to agents directly. It talks to a Node sidecar, one JSON object per line over stdin and stdout. The sidecar runs on this Mac or on an SSH host, wherever the session's files are.

Each provider is a module that returns the same small shape: { owns(localId), accountStatus(accounts), handlers }. The core routes every command in providerFor: if the command carries an account, it goes to that account's provider. Otherwise it goes to whichever provider owns the session. A command a provider has no handler for gets an unknown_command reply, so a newer app talking to an older sidecar fails fast instead of hanging.

Adding a provider means adding sidecar/<name>.mjs and one case to the Provider enum in the app.

Four different process models

  • Claude uses the Agent SDK directly.
  • Codex gets one codex app-server process per account, set by CODEX_HOME. That account's sessions are threads on it.
  • Antigravity gets one agy process per session. When the model or permission mode changes, the process is reopened with --conversation on the next turn.
  • OpenCode gets one opencode acp process per session. Permissions come from the mode through OPENCODE_CONFIG_CONTENT. If a mode change alters them, the process restarts and the session continues through session/resume.

Extra accounts get their own isolation: a CODEX_HOME for Codex, a separate XDG_DATA_HOME for OpenCode, and for Antigravity a home folder that links your real one except .gemini and the keychain folder.

One event shape

Providers speak to the app in the shape of Claude SDK events: assistant, user, stream_event, result, system. The transcript, permission cards and limit bars are drawn by one piece of code for all four.

The interesting work is in the tool names. The app already knows how to render Bash, Read, Grep, Edit and Write, so each provider's items are translated into those:

  • Codex commandExecution becomes Bash, or Read and Grep when Codex reports a single read or search action. fileChange becomes Write for a new file and Edit or MultiEdit for an update, with the unified diff split into hunks. A plan item becomes TodoWrite.
  • OpenCode reports ACP tool kinds: execute, read, edit, search, fetch. They map to Bash, Read, Edit, Grep or Glob, and WebFetch or WebSearch.
  • Antigravity's steps map to the same names, with some special handling. Its edit events carry no diff, so the previous content comes from what the agent last saw, or from git HEAD.

Codex transcript ids are <turnId>/<itemId>. That is what lets branching and rewinding work through thread/fork with a lastTurnId.

Do not drop data in translation

The easy translation keeps the text and throws the rest away. We made that a rule: images, errors, file lists and usage must survive. Conversions that more than one provider needs live in util.mjs, for example resultContent and imageOf for images in tool results.

Switch off what is missing, do not fake it

Providers differ in what they can do, and pretending otherwise produces buttons that do nothing. Every difference is a capability on the Provider enum, and views read the capability instead of branching on the provider:

  • restoresFiles: only Claude, through SDK file checkpoints. Rewinding a Codex session rewinds the conversation, not your files.
  • canBranch: Claude and Codex. Antigravity and OpenCode conversations cannot be cut at a turn.
  • canSwitchAccounts: Claude and Codex. A started session can only move to another account of the same provider, and for Antigravity and OpenCode the conversation lives in the account's folder.
  • hasPlanLimits: everyone except OpenCode, which runs on provider keys and reports no plan limit.
  • listsModels: Claude's model catalog is fixed. The others ask the sidecar.

Permission modes follow the same idea. Each provider declares its own list, with its own names, and the ⇧⇥ cycle differs. Antigravity cannot ask for permission mid-turn, so its mode decides up front what it may do, and a refused tool appears in the transcript as denied.

The rule is simple: if a provider cannot do something, we add a capability and hide or explain the control. UI strings use provider.title rather than a hard-coded "Claude".

How we test it

A fake provider plays scripted scenarios, so the Workflows engine and the Delegation tools are tested without a real model. For real CLIs we keep a per-provider checklist: user input and attachments, tool calls and results with images, permission requests, model, effort and mode, history loading and resume, branching, usage and limits, and running on an SSH host. Raw agy events can be dumped with BEYDOCK_AGY_DEBUG=1.

Every feature in BeyDock is expected to cover all four providers, or to say clearly which it does not.

Every agent. One dock.