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-serverprocess per account, set byCODEX_HOME. That account's sessions are threads on it. - Antigravity gets one
agyprocess per session. When the model or permission mode changes, the process is reopened with--conversationon the next turn. - OpenCode gets one
opencode acpprocess per session. Permissions come from the mode throughOPENCODE_CONFIG_CONTENT. If a mode change alters them, the process restarts and the session continues throughsession/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
commandExecutionbecomesBash, orReadandGrepwhen Codex reports a single read or search action.fileChangebecomesWritefor a new file andEditorMultiEditfor an update, with the unified diff split into hunks. Aplanitem becomesTodoWrite. - OpenCode reports ACP tool kinds:
execute,read,edit,search,fetch. They map toBash,Read,Edit,GreporGlob, andWebFetchorWebSearch. - 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.