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
`ProviderDriver`s 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`:

```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:

```ts
{ 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

| | 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** |

## The contract we are signing up to

### Command line

| 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.

### 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`):

| 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.

### 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.