Chapter 7 · Architecture
Adapters: one interface, many providers
How T3 Code runs eleven coding agents and seven Git hosts behind one interface each, how a small adapter is built on a shared one, and how capability flags decide which buttons the UI shows.
T3 Code lets you pick the agent that does the work, such as Codex, Claude, Cursor, OpenCode, or Kiro. It also opens pull requests on GitHub, GitLab, Bitbucket, and other hosts. Each of those outside systems speaks its own protocol, supports a different set of features, and fails in its own ways. Yet the code that runs a thread never asks “is this Claude?” before it acts.
This chapter shows how. It covers the shared interface every provider implements, one small real adapter, and the capability flags that let the server and the UI decide what to offer. If you have only connected one app to one or two APIs, this is the pattern that keeps the number of if (provider === ...) checks at zero as the list grows.
One list of drivers, many instances
TL;DR
Every supported agent is one entry in a list. The rest of the server works with whatever is in that list. One kind of agent can also run several times with different accounts, so the server tracks each configured copy separately.
A provider in T3 Code is an outside agent the app can drive. The server registers every built-in one in a single array:
export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv>> = [
CodexDriver,
ClaudeDriver,
CursorDriver,
GrokDriver,
FxDriver,
OpenCodeDriver,
AntigravityDriver,
KiroDriver,
PiDriver,
MuseDriver,
AcpRegistryDriver,
];That is eleven drivers. The last one, AcpRegistryDriver, covers a whole family. ACP, the Agent Client Protocol, is a shared protocol that several agents speak, and the registry driver runs any agent listed in the public ACP registry with no code of its own.
Two words matter here. A driver is the code for one kind of agent. An instance is one configured copy of a driver, with its own settings and account. The internal guide gives the reason for the split. “Route work by instance, so two accounts using the same driver do not share mutable session or catalog state” (providers.md). You can have a work Claude and a personal Claude, and the server keeps their sessions apart.
Each provider lives in its own workspace package, packages/provider-<name>, and exports the same four entry points. The checklist for new providers lists them (adding-a-provider.md):
./settingsholds the settings schema../clientholds the label, icon, and settings form data. It “must stay browser- and React Native-safe”, so web and mobile can import it../serverholds the driver and its adapter../testingexposes internals that replay tests drive.
The same rules cover where a package may import from. Server code in a provider imports only provider-core, contracts, and shared, and “reaches the server through ProviderHost”, never through apps/server. This suggests the aim is that you can read and change a provider without knowing the server’s internals.
The adapter interface
TL;DR
Every provider fills in the same small interface. It reports what it can do, and it opens a session. The session has a fixed set of required actions and some optional ones, and every event it reports uses the app’s own words, not the agent’s.
An adapter is the piece that translates between T3 Code and one agent’s native protocol. In T3 Code it is an Effect service, a named interface that other code asks for by tag. The top level is short:
export class ProviderAdapterV2 extends Context.Service<
ProviderAdapterV2,
{
readonly instanceId: ProviderInstanceId;
readonly driver: ProviderDriverKind;
readonly getCapabilities: () => Effect.Effect<
OrchestrationV2ProviderCapabilities,
ProviderAdapterV2Error
>;
readonly planSelectionTransition: (
input: ProviderSelectionTransitionInput,
) => Effect.Effect<ProviderSelectionTransitionPlan, ProviderAdapterV2Error>;
readonly openSession: (
input: ProviderAdapterV2OpenSessionInput,
) => Effect.Effect<ProviderAdapterV2SessionRuntime, ProviderAdapterV2Error, Scope.Scope>;
}
>()("@t3tools/provider-core/server/ProviderAdapter/ProviderAdapterV2") {}If you have not used Effect, read Effect.Effect<A, E, R> as “work that succeeds with A, can fail with E, and needs R”. So openSession returns a live session, can fail with a provider error, and needs a Scope, which is Effect’s way to tie a resource’s lifetime to a block of code so its cleanup always runs when that block ends. The Effect chapter covers this in more depth.
The session it returns is where the real work happens. Some methods are required and some are optional, and the difference is visible in the type:
readonly startTurn: (
input: ProviderAdapterV2TurnInput,
) => Effect.Effect<void, ProviderAdapterV2Error>;
readonly compactThread?: (
input: ProviderAdapterV2TurnInput,
) => Effect.Effect<void, ProviderAdapterV2Error>;
readonly steerTurn: (
input: ProviderAdapterV2SteerInput,
) => Effect.Effect<void, ProviderAdapterV2Error>;Every provider must start a turn and handle steering (a message sent while the agent is still working). compactThread has a ?, so a provider without its own compaction can leave it out.
The session also exposes events, a stream of things that happened. This is the most important design choice in the interface. Adapters do not forward the agent’s raw messages. They emit events in T3 Code’s own vocabulary, such as a node in the work tree being updated:
Schema.Struct({
type: Schema.Literal("node.updated"),
driver: ProviderDriverKind,
node: OrchestrationV2ExecutionNode,
}),OrchestrationV2ExecutionNode is the same schema the rest of the server stores. By the time an event leaves the adapter, nothing downstream can tell which agent produced it, apart from the driver label. The internal guide states it as one rule. “Provider-specific behavior stays in the adapter and driver. Orchestration and clients read capabilities, never the driver kind” (adding-a-provider.md).
A small adapter built on a shared one
TL;DR
Kiro speaks a standard protocol, so its adapter reuses the shared one for that protocol. Kiro’s own code only lists what Kiro does differently, starting with a copy of the default feature list where a few flags are switched on.
The Kiro adapter is a good first read because most of its work is done elsewhere. Kiro speaks ACP. The checklist says ACP agents start as registry entries with no custom code, and an agent “gets a dedicated driver only when it needs behavior the spec does not cover, and then it is a small flavor over the shared ACP adapter” (adding-a-provider.md). Julius Marminge’s PR #14693, which added Kiro, gives the reason it needed one. “Kiro isn’t in the ACP Registry, and generic Registry agents stay spec-only.”
Start from the defaults, then override
The shared ACP adapter exports a full capability object for a plain, spec-only agent (AcpProviderCapabilitiesV2). In it, canForkThread is false, supportsActiveSteering is false, and supportsSubagents is false. Kiro spreads those defaults and switches on what it really supports:
const KiroProviderCapabilitiesV2 = {
...AcpProviderCapabilitiesV2,
sessions: {
...AcpProviderCapabilitiesV2.sessions,
supportsModelSwitchInSession: true,
},
// Kiro 2.27 and later fork at a turn's prompt (`_meta.kiro.messageId`); see kiroForkFromTurn.
threads: {
...AcpProviderCapabilitiesV2.threads,
canForkThread: true,
canForkFromTurn: true,
},
// ...
turns: {
...AcpProviderCapabilitiesV2.turns,
supportsActiveSteering: true,
activeSteeringInterruptsTools: false,
// `_session/steer` takes a string, so a message with images restarts the turn.
activeSteeringAcceptsAttachments: false,
},
// ...
} satisfies OrchestrationV2ProviderCapabilities;Two details are worth copying into your own code. The comments name the protocol fact behind each flag, such as _session/steer taking a plain string. And satisfies OrchestrationV2ProviderCapabilities makes TypeScript check the whole object against the shared type. If the type gains a new required flag, this file stops compiling until Kiro states a value for it.
That one flag, activeSteeringAcceptsAttachments: false, has a visible effect. The PR description says “A message with images stops the turn and starts a new one instead, because Kiro’s steering takes text only.” The adapter does not hide the limit. It reports it, and the server picks a fallback.
The flavor holds the differences
Everything else Kiro-specific goes into a “flavor”, an object of hooks the shared adapter calls at fixed points. Here is part of it:
return {
driver: KIRO_PROVIDER,
runtimeHarness: "Kiro",
capabilities: KiroProviderCapabilitiesV2,
// ...
makeRuntime,
promptFailure: kiroPromptFailure,
// ...
// See isKiroPromptActivity: Kiro 2.27.1 can drop a cancel sent just after
// `session/prompt` and run the turn to completion.
cancelAfterPromptStarts: { isStart: isKiroPromptActivity, bound: KIRO_CANCEL_HOLD_BOUND },
permissionDisposition: kiroPermissionDisposition,
approvalOptions: kiroApprovalOptions,
compactSession: compactKiroSession,
steering: kiroSteering,
forkFromTurn: kiroForkFromTurn,The cancelAfterPromptStarts hook matches a trap the checklist says several providers hit. “A cancel that reaches the provider before it registered the prompt can be ignored while the turn runs to completion” (adding-a-provider.md). The shared adapter owns the fix, and Kiro only supplies the rule for “the prompt has started”. The adapter file is 641 lines in total, compared with about 15,500 lines of source in provider-acp that it builds on, by a line count of the non-test files.
The last step wraps all of this into the service the server asks for:
export const makeKiroAdapterV2 = Effect.fn("makeKiroAdapterV2")(function* (
options: KiroAdapterV2Options,
) {
return yield* makeAcpAdapterV2({
instanceId: options.instanceId,
flavor: makeKiroAcpAdapterFlavor(options),
selfInvocation: options.selfInvocation,
...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),
...(options.testHooks === undefined ? {} : { testHooks: options.testHooks }),
});
});Capabilities decide what the product offers
TL;DR
Each provider reports a long list of yes or no answers about what it can do. The server picks its strategy from those answers, and the web and mobile apps show or hide buttons from the same answers. Git hosts work the same way, down to calling it a “merge request” on GitLab.
A capability is a fact a provider reports about itself, such as “can fork a thread from a past turn”. The full shape lives in the shared contract package as OrchestrationV2ProviderCapabilities, with groups for sessions, threads, turns, streaming, tools, approvals, planning, subagents, context, checkpointing, identity, and runtime policy.
The design doc for this system states the goal. “Provider behavior should be expressed through capabilities and policies, not provider-name conditionals spread across orchestration” (provider-capability-system.md). Compare that doc’s sketch with the schema in contracts and you will see the code has grown past it. The doc has no runtimePolicy group and no activeSteeringAcceptsAttachments flag. The doc explains the idea, and the schema in contracts is the source of truth.
The server picks a strategy
When you send a message while an agent is working, the server must decide how to deliver it. The decision reads only capabilities:
const decideSteeringExecution: CommandPolicyV2Shape["decideSteeringExecution"] = (input) => {
if (!input.forceRestart && input.capabilities.turns.supportsActiveSteering) {
return Effect.succeed("active_steering");
}
if (
input.capabilities.turns.supportsInterrupt &&
input.capabilities.turns.supportsSteeringByInterruptRestart
) {
return Effect.succeed("interrupt_restart");
}
return Effect.fail(
unsupported(
input,
input.forceRestart || input.capabilities.turns.supportsInterrupt
? "interrupt_restart_steering"
: "active_steering",
input.forceRestart
? "providerInstanceId cannot satisfy a required interrupt-and-restart"
: "providerInstanceId cannot steer active turns directly or by interrupt-and-restart",
),
);
};There are three outcomes. If the agent can take a message mid-turn, send it. If it can be stopped and restarted, stop it and start again with the new message. Otherwise fail with a typed “unsupported” error that names the missing capability. No branch mentions Kiro, Claude, or Codex.
The UI hides what will not work
The client side reads the same flags. This function, shared by web and mobile, decides whether to show “Fork from this response” on an assistant message:
export function canForkProjectedAssistantItem(input: {
readonly projectedItem: OrchestrationV2ProjectedTurnItem;
readonly capabilities?: OrchestrationV2ProviderCapabilities | undefined;
}): boolean {
const item = input.projectedItem.item;
if (item.type !== "assistant_message" || item.runId === null || item.status !== "completed") {
return false;
}
if (input.capabilities === undefined) {
// Historical and inherited rows may outlive their provider-session record.
// Keep the portable server-side fallback available when capability evidence
// is absent; a known incapable provider is rejected below.
return true;
}
const capabilities = input.capabilities;
const canForkNatively =
capabilities.threads.canForkThread &&
capabilities.threads.canForkFromTurn &&
capabilities.identity.nativeThreadIds === "strong";
return canForkNatively || capabilities.context.supportsFullThreadHandoff;
}The button shows when the agent can fork natively, or when the app can rebuild the conversation from a written summary instead. The web timeline calls it in TimelineAssistantRows.tsx, and the mobile feed calls the same function in ThreadFeed.tsx. Both apps call one function, so they cannot disagree about when the button appears.
The checklist says why the flags must be honest. “A capability left on that fails at runtime is a bug” (adding-a-provider.md). A button that appears and then errors is worse than no button.
The same pattern for Git hosts
Source control follows the same design. packages/source-control-core defines a SourceControlDefinition, plain data plus pure functions that both client and server can import, and one package per host fills it in. The registry in source-control-definitions lists seven hosts.
Even the words are data. GitHub’s definition says “PR”:
changeRequest: {
shortLabel: "PR",
shortLabelWithArticle: "a PR",
singular: "pull request",
plural: "pull requests",
},GitLab’s says “MR”, “an MR”, and “merge request” in the same fields (definition.ts). The core type even has a comment on shortLabelWithArticle explaining that it is “Spelled out because the article follows how the label is said: ‘an MR’.”
The actions a host supports are a set. GitHub lists eleven, including merge, draft, revert, and enable-auto-merge (lines 113-125). Bitbucket lists two, with the reason in a comment:
// No endpoint reopens a declined pull request, and nothing documented moves one in or out of
// draft, so neither is offered rather than failing when pressed.
changeRequestActions: new Set(["merge", "close"] as const),The pull request panel reads that set to build its quick actions (ThreadPullRequestsPanel.tsx). A Bitbucket user never sees a “Reopen” button that cannot work.
Testing against the interface
The *-testing packages give tests a fake that satisfies the same interface. fakePullRequestProvider is described in its own comment as “A pull request provider that can do everything and holds nothing”, and a test overrides only the methods it cares about. For agents, the checklist prefers recorded transcripts over hand-written fakes. “A test that drives the adapter with hand-written frames is a guess about the protocol” (adding-a-provider.md). The testing chapter covers those replays.
Adapters in your own projects
TL;DR
fud’s receipt parsers are already a clean adapter list. antre runs Threads and LinkedIn as two separate copies of the same features, including two sets of agent tools. That is fine at two networks. A shared interface starts to pay off at the third.
fud already has one
fud reads bank and shop emails and turns each into a transaction. Each sender gets its own parser, and every parser fills in the same small type:
export type Parser = {
id: string;
matchesSender: (sender: string) => boolean;
parse: (email: Readonly<EmailPayload>) => ParserOutput | null;
};The registry is a list, like BUILT_IN_DRIVERS, and parseEmail walks it. Better still, parseEmail checks each parser’s output before trusting it. Amounts must be safe integers, currencies must be three capital letters, and the direction must be debit or credit. T3 Code follows the same rule. It normalizes and validates at the adapter boundary, so nothing downstream needs to know which parser ran. Adding a sixth sender is one file and one line in the list.
antre runs two stacks side by side
antre posts to Threads and LinkedIn. Each network has its own tables in convex/schema.ts (linkedinAccount, linkedinSettings, linkedinPosts beside account, settings, posts), its own module (convex/linkedin.ts and convex/publishing.ts), its own routes under /app and /app/linkedin, and its own set of MCP tools. In src/routes/mcp/+server.ts you find create_draft, queue, and delete_post, and further down create_linkedin_draft on line 766, queue_linkedin_post, and delete_linkedin_post.
This is a fair choice for two networks that differ a lot. A Threads post has parts (a thread of several posts), replies, and synced metrics. A LinkedIn post is one text field of up to 3,000 characters. A shared interface written today would be shaped by only two examples, and it would probably be wrong.
What T3 Code shows is the cost that comes later. Both posts and linkedinPosts carry a status union with draft, queued, publishing, published, held, and failed, and the two copies have already drifted (linkedinPosts adds dispatchedAt, posts adds retry and attempt). An agent using the MCP server has to learn two tool names for every action. If you add a third network, that is a third copy of each.
When that day comes, the T3 Code move would be a Channel type with the shared verbs (publish, validate, and refreshToken), plus a capability object such as { multiPart: boolean, replies: boolean, metrics: boolean, maxLength: number }. The composer and the MCP tools would read the capabilities instead of the route path, the way ChannelSwitch.svelte branches on url.pathname.startsWith('/app/linkedin') today.
For a solo project, the full T3 Code machinery would be overkill. You do not need per-instance isolation for one account, a replay test harness, or a capability object with eleven groups. The parts worth taking are small. Keep one list of implementations and one type they all satisfy, and let the UI read flags instead of names.
Try it
TL;DR
Write down what each antre network can do as data, then use it for the post length limit. Then write a test that proves every fud parser is wired into the list.
- Give antre’s channels a capability object. Create
src/lib/channels.tsthat exportschannels = { threads: {...}, linkedin: {...} }, each withlabel,maxLength,multiPart, andreplies. Set the LinkedIn limit fromMAX_LINKEDIN_TEXTinsrc/lib/linkedin.tsand the Threads limit to 500. Usesatisfies Record<string, ChannelCapabilities>so a missing field failssvelte-check. Then replace the hard-coded Threads limit in two places,const MAX = 500inComposer.svelteand the.refinecheck incompose/+page.server.ts, withchannels.threads.maxLength. It works whennpm teststill passes andrg -n "500" src/routes/app/composefinds only the CSSfont-weight. - Prove every fud parser is registered. In a new
tests/parsers-registry.test.ts, importregistryfromdomain/parsers.tsand the parser objects fromdomain/bca.ts,domain/klikbca.ts,domain/grab.ts, anddomain/apple.ts. Assert that each parser is in the list and that everyidis unique. Runpnpm test. Then comment out one entry inregistryand run it again. It worked if the test now fails and names the missing parser.