Vectrum
Sign In
306 lines · 16.5 KB

Codey as a T3 Code provider — design

Question: can T3 Code drive Codey without patching T3 Code? If so, through which of its provider slots, and what exactly does Codey have to say?

Answer: yes. Speak ACP over stdio, put the binary path in the Grok provider's settings, and T3 Code drives Codey with no source change on their side. Everything below is evidence for that choice and the contract it commits us to.

Researched against github.com/pingdotgg/t3code at commit 9accc567 (2026-09-19), which is the same generation as the 0.0.43 nightly installed on this machine. Every file reference is into that tree.

How T3 Code's provider layer is built

T3 Code is a control plane, not an agent: it spawns somebody else's CLI and renders what comes back. There are six drivers — Codex, Claude, Cursor, Grok, OpenCode, Antigravity (apps/server/src/provider/builtInDrivers.ts:50-56).

Three facts decide everything:

Drivers are compiled in. BUILT_IN_DRIVERS is "the static set of ProviderDrivers this build ships with" (apps/server/src/provider/builtInDrivers.ts:1-22). There is no plugin mechanism, no config-declared driver, no external process registry. Adding a seventh driver means editing their repo and rebuilding the Electron app.

But driver kinds are an open slug, on purpose. ProviderDriverKind is "an open branded slug, not a closed literal union … The server hosts forks, ships in PRs that add drivers, and users frequently roll between branches and forks" (packages/contracts/src/providerInstance.ts:16-33). Settings naming a driver this build does not know round-trip intact and surface as an "unavailable" card. So a fork that adds a codey driver is a supported shape — it is just not a shape that survives the auto-updater.

Each instance carries user-editable config, and there can be several per driver. ProviderInstanceConfig is {driver, displayName?, accentColor?, environment?, enabled?, config?} (packages/contracts/src/providerInstance.ts:124-131), keyed by a user-chosen instance id, and every built-in driver sets supportsMultipleInstances: true (e.g. Drivers/GrokDriver.ts:60-61). The per-driver config payload always contains binaryPath, and usually launchArgs and customModels (packages/contracts/src/settings.ts:562-735). This is the seam. On this machine it is already visible in ~/.t3/userdata/settings.json:

"providerInstances": {
  "codex": { "driver": "codex", "enabled": false,
    "config": { "binaryPath": "codex", "homePath": "", "shadowHomePath": "",
                "launchArgs": "", "customModels": [] } }
}

binaryPath is used verbatim — nothing anywhere keys off the file's name. Per-instance environment variables are editable in the same UI (docs/user/install.md:130-133).

So the plan the user proposed is the right one: don't add a driver, occupy one.

The four candidate surfaces

Claude — rejected

T3 Code does not spawn claude itself. It hands the path to Anthropic's own SDK: "@anthropic-ai/claude-agent-sdk": "^0.3.276" (apps/server/package.json:26), pathToClaudeCodeExecutable: claudeBinaryPath (Layers/ClaudeAdapter.ts:4916). The contract is therefore the SDK's CLI contract, which lives in a node package T3 Code upgrades on its own schedule, and which we would have to re-derive by experiment rather than read.

Worse, the model picker is a baked-in manifest with version gates: provider/model-manifest.json gives claude-opus-5 "minVersion": "2.1.219", and ClaudeModelCatalog.ts:148-158 refuses a model whose minVersion exceeds what claude --version printed. Our own model names would never appear; we would be advertising Anthropic slugs and lying about a Claude Code version.

Codex — possible, expensive

Spawned as <binaryPath> app-server plus the user's launchArgs (Layers/codexLaunchArgs.ts:12-15), newline-delimited JSON-RPC over stdio, no jsonrpc field, notifications must carry no id key at all (packages/effect-codex-app-server/src/protocol.ts:82-95, 459-474).

It has one genuinely attractive property the others lack: models come from the binary at runtime. The probe pages through model/list and shows what it is told (Layers/CodexProvider.ts:325-341), so Codey's own model names would appear in T3 Code's picker. There is no version gate; the version is scraped out of the initialize result's userAgent with /\/([^\s]+)/ (Layers/CodexProvider.ts:422-424).

Against it: the protocol is enormous and strictly decoded. 99 client request methods, 70 server notifications (_generated/meta.gen.ts:6-191), generated from a pinned upstream ref and schema-checked per method, so a missing required field is a hard decode error rather than a shrug. The minimum is bigger than it looks — thread/start must return a fully populated thread object (cliVersion, sessionId, preview, ephemeral, createdAt, updatedAt, source, status, turns) of which T3 Code reads exactly three fields (_generated/schema.gen.ts:31819-31855, consumed at Layers/CodexSessionRuntime.ts:2447-2453). And account/read must answer requiresOpenaiAuth: false with an account object or the probe stops (Layers/CodexProvider.ts:426-434).

It is implementable — the full method-by-method spec is in the appendix — but it is a moving target owned by OpenAI and mirrored by T3 Code.

OpenCode — rejected

T3-managed OpenCode runs one HTTP server per thread with directory-scoped MCP registration and per-directory persistent approval grants (docs/internals/providers.md:13-24). We would be impersonating a server, not a CLI, and inheriting its grant semantics.

Grok — chosen

Grok is an ACP agent. T3 Code spawns:

{ command: grokSettings?.binaryPath || "grok",
  args: [...grokAcpSpawnArgs(runtimeMode)],
  cwd,
  env: { ...environment, GROK_OAUTH2_REFERRER: "t3code" } }

(acp/GrokAcpSupport.ts:48-63), where grokAcpSpawnArgs is ["agent", "stdio"] by default and prefixes ["--permission-mode", "default" | "acceptEdits" | "auto"] or appends --always-approve for the four runtime modes (acp/GrokAcpSupport.ts:33-46).

That is the whole launch contract. The ACP side is four methods and one notification, and ACP is an open, documented protocol rather than a vendor mirror. Crucially, the initialize result has exactly one required field: protocolVersion; agentCapabilities, authMethods, agentInfo and promptCapabilities are all optionalKey (packages/effect-acp/src/_generated/schema.gen.ts:6574-6605). Unknown sessionUpdate discriminators hit default: break (acp/AcpRuntimeModel.ts:882-883), so we can grow into the protocol instead of having to land on it.

The two shell probes that gate the provider card are regex-loose:

  • grok --version must exit 0 and print something matching /\bv?(\d+\.\d+\.\d+)\b/ (Layers/GrokProvider.ts:394, parser at providerSnapshot.ts:114-119).
  • grok models must exit 0. Login state is /you are logged in/i versus /not authenticated|not logged in/i; models are bullet lines * slug (default) or - slug (Layers/GrokProvider.ts:258-286).

Compare Antigravity, which fingerprints the agent properly — agentInfo.name === "antigravity-acp" plus four required capabilities (AntigravityInstallation.ts:493-499). Nothing of the sort exists on the Grok path: no filename check, no minimum version, no config file under ~/.grok.

Cursor is nearly as cheap but adds three obligations: an about --format json probe that must report a YYYY.MM.DD cliVersion and a non-null userEmail or the card goes to status: "error" (Layers/CursorProvider.ts:1086-1097, 932-952); an unconditional session/set_config_option on model selection (acp/CursorAcpSupport.ts:113); and hardcoded permission option ids "allow-always" / "allow-once" / "reject-once" that the agent must accept verbatim (acp/AcpAdapterSupport.ts:46-55). Grok, by contrast, echoes back an option id chosen from the ones we advertised (Layers/GrokAdapter.ts:1219-1237).

Scoring

ClaudeCodexOpenCodeCursorGrok
Transport we must implementSDK-privateJSON-RPC, 99+70 methodsHTTP+SSE serverACPACP
Minimum methodsn/a4 req + 4 notif, strict schemasmany4 + probe4 + 1 notif
Startup gatesversion-gated model manifestaccount/read auth shapeserver lifecycleabout JSON + emailtwo regexes
Our own model names shownoyespartlyvia extensionvia models output
Protocol ownerAnthropic SDKOpenAI, mirroredSSTCursoropen standard
Fingerprints the agent————no

The contract we are signing up to

Command line

invocationmust do
codey --versionexit 0, print a line containing x.y.z
codey modelsexit 0, print You are logged in as … and one * slug (default) / - slug line per model
codey agent stdiospeak ACP over stdin/stdout until EOF
codey agent --always-approve stdiosame, with every tool auto-approved
codey --permission-mode default|acceptEdits|auto agent stdiosame, with that approval policy

codey login, codey update and any other subcommand may be absent; T3 Code only ever mentions them in error text. Note the flag placement: T3 Code puts --permission-mode before the agent subcommand and --always-approve between agent and stdio, so the argument parser has to be positional- agnostic rather than a strict agent subcommand with its own flag set.

ACP, client → us

  1. initialize — {protocolVersion: 1, clientCapabilities: {fs: {readTextFile: false, writeTextFile: false}, terminal: false}, clientInfo: {name: "t3-code", version}} (acp/AcpSessionRuntime.ts:696-705, 566-578). Minimum valid reply: {"protocolVersion": 1}. We will also send agentInfo and agentCapabilities.
  2. authenticate — {methodId: "cached_token"}, or "xai.api_key" if XAI_API_KEY is set in the instance env (acp/GrokAcpSupport.ts:65-69). Sent unconditionally and not caught: an error here aborts session start (acp/AcpSessionRuntime.ts:710-718). Reply {} regardless of the id.
  3. session/new — {cwd, mcpServers: [], additionalDirectories?}. Only sessionId is required back (acp/AcpSessionRuntime.ts:836-860).
  4. session/prompt — {sessionId, prompt: ContentBlock[]}. Only stopReason is read from the result (Layers/CursorAdapter.ts:1112).
  5. session/cancel — notification; T3 Code synthesises {stopReason: "cancelled"} locally (acp/AcpSessionRuntime.ts:1027-1029).

session/load / resume is only attempted when agentCapabilities.sessionCapabilities.resume is true (acp/AcpSessionRuntime.ts:726), so omitting it is a supported state, not a failure.

ACP, us → client

session/update notifications, switched on sessionUpdate (acp/AcpRuntimeModel.ts:795-884):

variantfields readwe need it
agent_message_chunkcontent.textyes — the visible answer
agent_thought_chunkcontent.textphase 2 (we have no reasoning events yet)
tool_calltoolCallId, title, kind, status, content, rawInputyes — otherwise tools are invisible
tool_call_updatesame, merged by toolCallId; terminal on completed/failedyes
planentries[].content, .statusno
available_commands_updateavailableCommandslater, for slash commands
current_mode_update, config_option_updatemode / config idsno

And session/request_permission — we send {sessionId, toolCall: {kind, title, rawInput, locations}, options: [{optionId, name, kind}]}; T3 Code replies {outcome: {outcome: "selected", optionId}} or {outcome: {outcome: "cancelled"}} (Layers/CursorAdapter.ts:746-754). On the Grok path the id comes from our own list (Layers/GrokAdapter.ts:1219-1237), so we choose the vocabulary — but we should still accept allow-always/allow-once/reject-once verbatim so that pointing the same binary at a Cursor instance also works.

What T3 Code will show

The card, icon and colour come from GROK_PRESENTATION, so out of the box it says Grok. The instance's displayName and accentColor are user-editable (packages/contracts/src/providerInstance.ts:124-131) and are stamped onto every event by withInstanceIdentity, so renaming the instance to "Codey" gets most of the way. The xAI glyph stays. That is the price of not forking.

Model slugs pass through resolveGrokAcpBaseModelId and get a display name from displayNameFromGrokModelSlug, which title-cases on -/_ (Layers/GrokProvider.ts:272-293). codey-opus-5 renders as "Codey Opus 5".

What this costs on our side

Codey today is a TUI and a -p one-shot. It already has the two seams an adapter needs — Agent.Observe func(Event) and Agent.Approve Approver (internal/agent/loop.go:205-211) — plus a tool registry, a session store and an approval policy that can remember decisions. The new work is a third front end: internal/acp (wire) + internal/serve (bridge), and an agent stdio verb in cmd/codey. Nothing in internal/agent needs to change for phase 1.

Three gaps show up when mapping our Event onto ACP:

  • Event has no tool call id. EventToolStart/EventToolResult carry Tool string and nothing that pairs them (internal/agent/loop.go:180-191). ACP's tool_call /tool_call_update are keyed by toolCallId. Sequential dispatch means we can pair by order for now, but the honest fix is to add an id to the event.
  • No reasoning events. The loop never surfaces thinking blocks to observers, so agent_thought_chunk has nothing to carry.
  • No diff events. Our edit/write tools return text; T3 Code's diff view wants tool_call content of kind diff with old/new text.

None of these block a working turn; all three are what separate "it works" from "it looks like a first-class provider".

Risks

  • Their churn. T3 Code ships nightly and the Grok path is explicitly young: the driver is disabled by default because "the binding is not yet stable enough to probe on every install" (packages/contracts/src/settings.ts:713-716). Mitigation: the contract surface we depend on is five methods, and a conformance test that replays T3 Code's exact handshake catches a break in seconds.
  • Occupying a slot means losing it. A user who wants both real Grok and Codey needs two instances of the grok driver. That works (supportsMultipleInstances: true) but it is a confusing settings page.
  • It says Grok. See above.
  • Upstream ACP version. T3 Code sends protocolVersion: 1. If they move to 2 we have to follow; the handshake is where we would find out.

The fork option, for the record

Because driver kinds are open and forks are anticipated, a codey driver is about one file (Drivers/CodeyDriver.ts), one line in BUILT_IN_DRIVERS, and a settings schema — additive, no rewriting, and it would get us our own name, icon and config fields. The cost is that it only exists in a build we compile and ship ourselves, and it has to be rebased onto a repo that moves daily.

The right sequencing is impersonation first: the ACP server we build for the Grok slot is exactly the server a codey driver would talk to, so nothing is thrown away if we later decide the fork is worth maintaining.

Appendix: the Codex app-server contract

Kept because it is the fallback if the Grok path is withdrawn, and because it is the only slot that lets our model names reach the picker honestly.

Launch: <binaryPath> app-server [launchArgs], CODEX_HOME set in the child env, tilde-expanded (Layers/CodexProvider.ts:368-378).

Must answer:

  • initialize → {codexHome, platformFamily, platformOs, userAgent}, all four required; version is scraped from userAgent after the first /.
  • thread/start → {approvalPolicy, approvalsReviewer, cwd, model, modelProvider, sandbox, thread{...}} — the strictest decode in the flow.
  • turn/start → {turn: {id, items: [], status: "inProgress"}}.
  • turn/interrupt → {}.
  • probe path: account/read → {requiresOpenaiAuth: false, account: {...}}; model/list → {data: [...]}; skills/list (a failure here fails the whole probe, unlike account/rateLimits/read, which degrades).

Must emit: turn/started, item/agentMessage/delta, item/completed, turn/completed — the last is what ends the turn (Layers/CodexAdapter.ts:1613).

Traps: notifications must have no id key at all; inbound queues are sliding(32) so bursts drop oldest; a server→client request left unanswered blocks T3 Code's entire stdin loop (Layers/CodexSessionRuntime.ts:2565-2571); there is no shutdown RPC — exit on stdin EOF.