Reading T3 Code

Chapter 4 · Architecture

One server, many clients

Why T3 Code runs all the real work in one server per machine, how web, desktop, and mobile stay thin clients of it, and what that buys them.

11 min read

Most web apps you have built put the logic on a server and the screens in a browser. T3 Code takes that split to its conclusion. It has a web app, a desktop app, and a phone app, and none of them does the work. A coding agent edits files, runs a terminal, and talks to Git. All of that happens in a server that runs on the machine holding the code. Every app is a remote control for that server. This chapter shows how the maintainers drew that line, what crosses it, and how they keep three different clients from drifting apart.

One environment owns the work

TL;DR

The computer with your code runs the server, and the server does everything real: agents, terminals, Git, files. The apps only show things and send requests. Even the desktop app, which starts a server for you, talks to it like any other app would.

The architecture overview opens with the rule everything else follows.

t3code/docs/internals/overview.md:3-6
T3 Code keeps execution in the environment that owns the workspace. Web, desktop, and mobile
clients control it over authenticated RPC. A remote client must never substitute its own filesystem,
provider credentials, or machine state for the environment's. The desktop app bundles a server,
but its renderer follows the same boundary.

An environment is one running T3 server plus the machine, files, and credentials it owns. A client is any of the three apps. The server wraps the coding agents’ own command-line tools, which T3 Code calls providers (Codex, Claude Code, Cursor, and others), and exposes them over a WebSocket (AGENTS.md).

The next paragraph of the overview says what lives on each side.

t3code/docs/internals/overview.md:10-14
Provider processes, terminals, Git, and project files belong to the server. Shared connection and
domain state belongs in `packages/client-runtime`; clients supply platform services and UI.
Keeping that logic shared prevents reconnect and multi-environment behavior from diverging between
web and mobile. See [connection runtime](./connection-runtime.md) and
[remote environments](./remote.md).

Why be this strict? Picture the alternative. Suppose the phone app ran git status itself, or the web app read a file from the browser’s machine. Each would be right on the device you tested and wrong everywhere else. A phone has no checkout. A browser on your couch isn’t the laptop where the repo lives. Putting all execution in one place means there is one answer to “what is the state of this project”, and every client asks the same server for it.

Surface What it is Where its code lives
Server Node process that owns agents, terminals, Git, files apps/server
Web React app, served by the server or at app.t3.codes apps/web
Desktop Electron shell around the web app, plus a bundled server apps/desktop
Mobile React Native app for iOS and Android apps/mobile
Shared client logic Connections, state, commands packages/client-runtime
The wire Every message shape packages/contracts

Three clients, one runtime

TL;DR

The three apps share one library that handles connecting, reconnecting, and keeping data in sync. Each app only adds what its platform is good at, like storage or knowing when the phone wakes up. That way a fix to reconnecting lands everywhere at once.

If each client wrote its own connection code, you would get three slightly different reconnect bugs. packages/client-runtime exists to prevent that. Its own doc puts it plainly.

t3code/docs/internals/connection-runtime.md:3-7
Web, the desktop renderer, and mobile share one connection owner per environment
in `packages/client-runtime`. Platform code supplies storage, credentials, network
signals, and application lifecycle events. React views consume the runtime.
Keeping retries and session lifetime here prevents competing reconnect loops when
several views need the same environment.

Notice the shape of the split. The shared runtime holds the logic. Each platform plugs in the few things only it knows. A phone knows when it was backgrounded, a browser knows its storage, and Electron knows its secure keychain. A count at the pinned commit shows how central the runtime is. 593 files in apps/web/src and 427 in apps/mobile/src import @t3tools/client-runtime.

The desktop app adds very little on top. AGENTS.md describes it as “wraps web, adds Electron shell/IPC” (AGENTS.md). The desktop window renders the same React app as the browser. Electron-only abilities, such as native menus or window control, cross a separate IPC boundary that also has its own contract file, packages/contracts/src/ipc.ts.

What happens on one socket

TL;DR

When an app connects, the server checks who it is once, then checks again for every single request whether that login is allowed to do that thing. And the server only sends each app the data it’s looking at, not everything.

Each client opens one WebSocket to the server and speaks RPC over it. The client calls a named method with typed input and gets a typed result or a typed error back. The list of methods lives in the contracts package. In apps/server/src/ws.ts, every new socket gets its own RPC server, built from that list and wrapped in an authorization layer for this session’s scopes.

t3code/apps/server/src/ws.ts:3472-3478
        const rpcWebSocketHttpEffect = yield* Effect.gen(function* () {
          const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket;
          yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe(
            Effect.provideService(RpcServer.Protocol, withOutputWindow(protocol)),
            Effect.provide(
              Layer.merge(
                RpcAuthorization.layer(session.scopes, (method, payload) => {

Two ideas hide in those lines. First, logging in isn’t the same as being allowed. The overview says “authentication of a socket does not authorize every method on it” (overview.md). A phone paired with read-only scopes can watch a thread but not open a terminal, even on the same socket. The table that maps each method to its required scope is typed so that every method must appear in it (RpcAuthorization.ts).

Second, the server only sends what a client needs. “Subscriptions send the state a client needs, so a client viewing one thread does not pay for every thread’s history” (overview.md). This is the “remote ready” pillar from AGENTS.md showing up as a design rule. A phone on a weak mobile connection can’t afford to download the full history of thirty threads to show one.

Acknowledgements are honest about what they mean. When a client sends a command, such as “start a turn”, the server records the intent in its database first and does the slow work afterward. The overview spells out the consequence: “A command acknowledgement therefore means the intent committed, not that the provider, checkpoint, or other follow-up work finished” (overview.md). The client then learns about progress through the same subscriptions it uses for everything else. If you want the full story of that event machinery, see Orchestration v2.

Staying connected when networks lie

TL;DR

Phones sleep, Wi-Fi drops, and sockets die without saying so. The shared library has one place that decides when to retry, adds randomness so thousands of apps don’t reconnect at the same second, and tests whether a quiet connection is actually dead.

A remote control is only useful if it stays connected, and networks are bad at saying when they have failed. The connection runtime doc is one of the most practical documents in the repo, because each rule comes with its reason. The supervisor owns retries, and the doc explains each part of its policy.

t3code/docs/internals/connection-runtime.md:14-17
five minutes, that resets only after a connection stays up. Without jitter, every
client of a restarted server reconnects in the same second; with a short cap, a
client that can never connect retries all day. Offline states and authentication
failures wait for a wakeup instead of spending attempts on unchanged conditions.

Read it as three decisions, each defending against a specific failure.

  • Jitter. Each client waits a slightly random amount before retrying. Without it, a server restart is followed by every client reconnecting at the same instant.
  • A five-minute cap. Waits grow, but not forever, and they reset only after a connection stays up. A client that can never connect doesn’t hammer the server all day.
  • Don’t retry what can’t change. If the device is offline or the login was rejected, retrying is pointless. The runtime waits for a signal, such as the network coming back or the app coming to the foreground.

The same doc describes how the runtime detects a socket the operating system killed without a close event. It sends a probe, and if the probe goes unanswered it asks the host over a fresh request. A host that answers while the socket stays silent proves the socket is dead (connection-runtime.md). You don’t need to memorize the timings. What to take away is the habit of writing the reason next to every number, so the next person knows what breaks if they change it.

The desktop app is a client too

TL;DR

The desktop app starts a server in the background and then connects to it like a stranger would. Because it doesn’t take shortcuts, anything that works on desktop also works from a phone across the room.

Desktop is the surface most people install first, and “it’s a full Electron app that bundles the server runner as well” (AGENTS.md). You might expect the desktop window to call server functions directly, since they ship together. It doesn’t. The Electron main process starts the server as a separate child process.

t3code/apps/desktop/src/backend/DesktopBackendManager.ts:460-473
  const command = ChildProcess.make(options.executablePath, options.args, {
    cwd: options.cwd,
    env: options.env,
    // ...
  const handle = yield* spawner.spawn(command).pipe(

The window then connects to that server over the same authenticated WebSocket any other client uses. The constants at the top of DesktopBackendManager.ts show that it treats the server as something that can be slow or fail. It allows over a minute for a large database to become ready, gives a grace period on shutdown, and backs off after three restarts without readiness.

This costs a little speed and buys a lot of correctness. Since the desktop window has no private shortcut, the desktop app can also act as the host for a phone or a browser elsewhere (AGENTS.md). A feature that works in the desktop window is already a feature that works remotely.

The same thinking explains one of the “three ways to hurt yourself” in AGENTS.md. In development you must never set VITE_HTTP_URL or VITE_WS_URL, because “dev is single-origin” and setting them “bakes localhost into the bundle and silently breaks every remote browser” (AGENTS.md). A client that hard-codes where its server lives has stopped being a client of whatever environment it was opened from.

How your projects split server and client

TL;DR

fud already has the same shape on a small scale: one backend that does the work and several things that talk to it. The difference is that the list of allowed requests is written by hand in two places, so it can fall out of sync.

fud has the same architecture in miniature, which makes it a good comparison. It deploys two Cloudflare Workers. fud-api runs worker/index.ts with the D1 database and a job queue (wrangler.jsonc). fud-web runs the SvelteKit app (wrangler.web.jsonc). The API Worker owns the ledger and does the real work, including the cron-driven Gmail sync and the queue consumer (worker/index.ts). It has more than one client, too. The web app reaches it through a server-side gateway, and AI agents reach it through the MCP endpoint in worker/mcp.ts with a separate kind of token. That is “one environment owns the work” at the scale of a personal app.

The gateway is where fud and T3 Code differ. src/lib/server/gateway.ts keeps an allowlist of the API paths the web app may call.

fud/src/lib/server/gateway.ts:6-15
const reads: Record<string, readonly string[]> = {
  dashboard: ["month", "today"],
  payee: ["name", "today"],
  gmail: [],
  ai: [],
};
const writes = new Set([
  "preferences",
  "payee-group",
  "repeating-end",

An allowlist is a good instinct. The browser can’t make the gateway call arbitrary internal paths. But the same names are written again as url.pathname === "/preferences" checks in worker/index.ts. If you rename a route on one side, nothing fails until a request returns 404. In T3 Code, both sides use one WS_METHODS constant from the contracts package, so the same rename is a compile error.

antre makes a different choice that is worth seeing clearly. Convex is its server, and both the SvelteKit app and the MCP route are clients of Convex. The MCP route builds a fresh ConvexHttpClient per request and passes the caller’s bearer token along as a secret (src/lib/server/convex.ts). That keeps the rule that the backend decides. The MCP route doesn’t touch data itself, it asks Convex. Convex also generates the typed api object from your functions, so method names there are already single-sourced.

How much of T3 Code’s machinery do you need? Very little. Your apps deploy their server and client together and run in browsers you control. You don’t need a connection supervisor, forward-compatible schemas, or a bundled server. The part that transfers is the boundary. Decide which program owns the work, make every other program ask it, and give the list of what may be asked one name in one file.

Try it

TL;DR

Give fud’s routes one shared list of names, and try reconnecting the way T3 Code does in a small script so you can see why the randomness matters.

  1. One list of routes for fud. Create worker/routes.ts that exports the read and write route names as const arrays and a RouteName type. Import it in both src/lib/server/gateway.ts (to build reads and writes) and worker/index.ts (to compare url.pathname). You’ll know it worked when renaming "payee-group" in routes.ts makes pnpm typecheck fail in both files, instead of producing a 404 at runtime.
  2. Feel the thundering herd. Write a small script with no dependencies, for example scripts/herd.ts in vibe-idx. It should simulate 1,000 clients reconnecting after a server restart with plain exponential backoff, record the second each one retries, and print the busiest second. Then add full jitter (wait a random time between zero and the backoff) and run it again. It worked if the busiest second drops by roughly an order of magnitude. That is the reason behind one sentence in connection-runtime.md.
  3. Map your own boundaries. Copy the surface table from the start of this chapter into fud/CONTEXT.md or README.md, with one row for each of your programs: fud-api, fud-web, the MCP endpoint, the cron, and the queue consumer. Write in one sentence what each one owns. If two rows claim the same thing, you’ve found the next bug to look for.

Text size

Theme