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.
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.
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.
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.
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 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).
| Claude | Codex | OpenCode | Cursor | Grok | |
|---|---|---|---|---|---|
| Transport we must implement | SDK-private | JSON-RPC, 99+70 methods | HTTP+SSE server | ACP | ACP |
| Minimum methods | n/a | 4 req + 4 notif, strict schemas | many | 4 + probe | 4 + 1 notif |
| Startup gates | version-gated model manifest | account/read auth shape | server lifecycle | about JSON + email | two regexes |
| Our own model names show | no | yes | partly | via extension | via models output |
| Protocol owner | Anthropic SDK | OpenAI, mirrored | SST | Cursor | open standard |
| Fingerprints the agent | — | — | — | — | no |
| invocation | must do |
|---|---|
codey --version | exit 0, print a line containing x.y.z |
codey models | exit 0, print You are logged in as … and one * slug (default) / - slug line per model |
codey agent stdio | speak ACP over stdin/stdout until EOF |
codey agent --always-approve stdio | same, with every tool auto-approved |
codey --permission-mode default|acceptEdits|auto agent stdio | same, 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.
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.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.session/new — {cwd, mcpServers: [], additionalDirectories?}. Only sessionId is required back (acp/AcpSessionRuntime.ts:836-860).session/prompt — {sessionId, prompt: ContentBlock[]}. Only stopReason is read from the result (Layers/CursorAdapter.ts:1112).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.
session/update notifications, switched on sessionUpdate
(acp/AcpRuntimeModel.ts:795-884):
| variant | fields read | we need it |
|---|---|---|
agent_message_chunk | content.text | yes — the visible answer |
agent_thought_chunk | content.text | phase 2 (we have no reasoning events yet) |
tool_call | toolCallId, title, kind, status, content, rawInput | yes — otherwise tools are invisible |
tool_call_update | same, merged by toolCallId; terminal on completed/failed | yes |
plan | entries[].content, .status | no |
available_commands_update | availableCommands | later, for slash commands |
current_mode_update, config_option_update | mode / config ids | no |
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.
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".
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.agent_thought_chunk has nothing to carry.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".
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.grok driver. That works
(supportsMultipleInstances: true) but it is a confusing settings page.protocolVersion: 1. If they move to
2 we have to follow; the handshake is where we would find out.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.
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 → {}.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.