Chapter 2 · How the repo is organized
The shape of the monorepo
What lives in each top-level folder of T3 Code, how its packages depend on each other in one direction, and when code earns its own package.
T3 Code is a monorepo. One Git repository holds five apps, about thirty shared libraries, two cloud services, some Rust and C, and the scripts that build and check all of it. Each library is a workspace package with its own package.json, and the apps depend on them with workspace:* versions.
On a solo project you can put a file anywhere and import it from anywhere. In a repo this size, that freedom is what turns it into a tangle. This chapter shows you the layout, the one rule that keeps it untangled (dependencies point one way), and how the maintainers decide when a folder should become a package.
What lives at the top level
TL;DR
Apps are things you run. Packages are things apps import. Infra is the cloud services. Native is the code that is not TypeScript. Scripts are the build and check tools. Learn which bucket a folder is in and you know what it may import.
The workspace config lists every folder that holds a package:
packages:
- apps/*
- evals
- infra/*
- oxlint-plugin-t3code
- packages/*
- scriptsSo scripts/, evals/, and the lint plugin are packages too, with their own dependencies. native/ is not in the list, because it holds no JavaScript.
| Folder | What is in it | Examples at the pinned commit |
|---|---|---|
apps/ |
Things you run or ship | server (the t3 command), web (React and Vite), desktop (Electron), mobile (React Native), marketing (the site) |
packages/ |
Libraries the apps import | contracts, shared, client-runtime, provider-*, source-control-*, secret-provider-*, ssh, tailscale |
infra/ |
Cloud services the team hosts | relay (the T3 Connect control plane), task-cache (a shared build cache) |
native/ |
Code that is not TypeScript | Rust crates such as resource-monitor and kde-snap-shot, a C program in browser-secret |
scripts/ |
Dev, check, and release tools | dev-runner.ts, check-changed.ts, build-desktop-artifact.ts |
The apps are large. apps/web has about 2,000 tracked files, apps/mobile about 1,750, and apps/server about 1,600. The biggest package, client-runtime, has about 400. Most code still lives in the apps. Packages are the exception, and each one has a reason to exist.
The four packages to know first
AGENTS.md describes the core packages in one line each. packages/contracts holds “Effect/Schema contracts plus small derived helpers. No heavy runtime logic.” packages/shared holds “shared runtime utils, subpath exports, no barrel.” packages/client-runtime holds “client code shared by web and mobile.” The provider-* packages each wrap one provider, such as Cursor or Pi.
Contracts matter most. Anything that crosses the wire between a client and the server is typed there, so a change to one schema shows up as a type error in the server, web, mobile, and desktop at once. To see how the contract package is built, read the chapter on contracts.
Dependencies point one way
TL;DR
Contracts depend on nothing of ours. Shared helpers depend only on contracts. Provider and forge packages sit on top of those. Apps sit on top of everything. Nothing lower ever imports something higher, and the build tools rely on that to know what a change can break.
Read the workspace:* entries in every package.json and a clear order appears. Here it is, lowest first:
contracts, effect-acp, effect-codex-app-server (no internal deps)
shared -> contracts
provider-core, source-control-core, ssh, -> contracts, shared
tailscale
secret-provider-core -> contracts
provider-pi, provider-cursor, ... -> provider-core, contracts, shared
source-control-github, ... -> source-control-core, contracts, shared
source-control-definitions, -> the packages each one lists
secret-provider-definitions
client-runtime -> contracts, shared, provider */client entries, definitions
apps/web, apps/mobile, apps/desktop -> client-runtime, contracts, shared, and a few more
apps/server -> nearly every packageNo package depends on an app. The one app that depends on another is apps/server, which lists @t3tools/web. It does that only to build the web app before the server, because the server serves the built files. Its config says so with dependsOn: ["@t3tools/web#build"].
Look at the bottom of the tree:
{
"name": "@t3tools/contracts",
// ...
"dependencies": {
"effect": "catalog:"
},@t3tools/contracts has one runtime dependency, effect. That is what lets every client import it. A React Native app, a browser, a Node server, and a Cloudflare Worker can all load a package whose only dependency is a schema library. If contracts imported a Node-only module, the mobile app would break.
Why one direction pays off
The direction is not only tidy. The tools use it. ci-changes.cjs decides which packages a pull request affects. Its first line reads “Unknown paths run every job; recognized packages pull in their dependents.” The loop that does the pulling is short:
let grew = true;
while (grew) {
grew = false;
for (const pkg of packages) {
if (!affected.has(pkg.name) && pkg.deps.some((dep) => affected.has(dep))) {
affected.add(pkg.name);
grew = true;
}
}
}It starts with the packages that own the changed files. Then it keeps adding any package that depends on one already in the set, until nothing new joins. Run decide on one changed file in provider-pi and it returns nine packages: provider-pi, the server, client-runtime, the web, mobile, and desktop apps, the relay (which imports client-runtime), and the evals and scripts packages. It leaves out contracts, shared, and every forge package, so CI skips their typecheck and tests. That shortcut is only safe because the graph has no cycles and no hidden imports. A relative import that reaches into another package’s folder would not show up in package.json, and CI would skip a package that it broke.
Lint rules guard the direction inside a package
package.json controls which packages may import each other. Inside one package, the root vite.config.ts adds lint rules for folders. Each source-control-* package has shared/, server/, and sometimes client/ folders. The shared folder loads everywhere, so it may not import UI code or server code:
// The server and every client load a forge's shared definition, so a UI runtime
// imported there would break the server; client-only forge code goes in `src/client/`.
files: ["packages/source-control-*/src/shared/**"],
// ...
group: [
"react",
"react/*",
// ...
"@t3tools/client-runtime",
"@t3tools/client-runtime/*",
"../client/*",
"../server/*",
// ...
],
message:
"Shared forge definitions load on the server and every client; keep UI and server code out.",The error message tells you the rule and the reason. You do not have to find a doc to learn why your import failed.
Subpath exports instead of barrel files
TL;DR
T3 Code packages expose each module under its own import path, and some have no single entry file at all. That way a phone app that needs one helper does not load the server code sitting next to it.
A barrel file is an index.ts that re-exports a whole package. It is convenient, and in a mixed monorepo it is a trap. If @t3tools/shared had a barrel, a mobile screen that imports one string helper would also load every Node-only module in the package.
So packages/shared has no root export. Its package.json lists 136 subpath exports, one per module, such as @t3tools/shared/git and @t3tools/shared/Net. Julius Marminge created the package in commit dde48e9 “with explicit subpath exports (no barrel)”. It took two branch-name helpers out of contracts to keep “contracts focused on schemas”.
client-runtime follows the same rule, and lint enforces it:
{
name: "@t3tools/client-runtime",
message:
"Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.",Provider packages go one step further. Each splits its exports by where they run. adding-a-provider.md says ./client “must stay browser- and React Native-safe” and ./server exports the driver. client-runtime then imports only the client halves, such as @t3tools/provider-pi/client and @t3tools/provider-cursor/client. The server half, with its subprocess code, never reaches a phone.
When code earns a package
TL;DR
T3 Code makes a package when two runtimes need the same code, or when it wants to force a boundary, such as “a provider may only reach the server through this one door.” It leaves code in the app when only that app uses it, and it moves code out in small steps.
The history shows three reasons the maintainers split code out, and one reason they do not.
Reason 1: more than one runtime needs it
This is why contracts, shared, and client-runtime exist. The overview says shared connection logic lives in client-runtime because “Keeping that logic shared prevents reconnect and multi-environment behavior from diverging between web and mobile.” Two copies of reconnect logic would drift. One package cannot.
Reason 2: to force a boundary
In October 2026, Julius opened PR #17299 to add provider-core. The PR body names the problem: “Providers could not live outside apps/server: the driver contract, the helpers drivers share, and every way a driver reached the server were server-internal.” The fix was one narrow interface, ProviderHost, described as “the one service a provider uses to reach the server”:
export class ProviderHost extends Context.Service<
ProviderHost,
{
readonly paths: ProviderHostPaths;
// ...
readonly shouldRunBackgroundWork: (scope: BackgroundScope) => Effect.Effect<boolean>;
// ...
readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;
// ...
readonly credentials: (
namespace: string,
bindingId: string,
) => Effect.Effect<ProviderCredentials>;
}
>()("@t3tools/provider-core/server/ProviderHost") {}This is an Effect service. A provider package asks for ProviderHost and gets paths, settings, a background-work check, attachment paths, and credentials. It cannot import anything else from the server, because the server is not in its package.json. The server implements ProviderHost once. The package boundary turns “please don’t reach into server internals” from a review comment into a build error.
The forge packages repeated the pattern a day later. PR #17573 added source-control-core with a SourceControlHost port, “mirroring ProviderHost”. Before it, “adding one means editing files across the repo.”
Reason 3: a family of interchangeable parts needs one list
When the server and the clients both need to know which forges exist, someone has to own the list. PR #24288 created a small source-control-definitions package “that the server and clients both import”. Its whole job is one ordered registry:
/**
* GitHub comes first to preserve the client default for repositories with an unknown host.
* GitCafe and Cursor Origin claim URLs first because GitHub and Forgejo accept the same routes on
* any hostname.
* Remote recognition needs its own order because host labels can overlap (`gitea.github.example`).
*/
export const builtInSourceControlDefinitions = makeSourceControlDefinitionRegistry(
[
GitHub.definition,
CursorOrigin.definition,
GitLab.definition,
GitCafe.definition,
Forgejo.definition,
Bitbucket.definition,
AzureDevOps.definition,
],The PR says the order “was written twice” before this change. A package with one file is fine when it removes a duplicate that can drift.
When they do not split
Most of the server is still one app. Three providers, Claude, Codex, and Antigravity, still have their drivers in apps/server/src/provider/Drivers. The provider guide says so plainly: “Drivers not moved yet still live in provider/Drivers.” The maintainers move a provider when they have a reason, one PR at a time, and they do not move everything up front. The source-control work ran as a stack of PRs. PR #24288 calls itself “Layer 11” of that stack.
This matches Theo’s note in AGENTS.md: “Do not introduce machinery because it looks architecturally impressive.” A package has costs. It adds a package.json, a typecheck task, an exports map to maintain, and a dependency edge that CI has to walk. Make one when it removes a real problem, such as two copies drifting or a boundary people keep crossing.
How your projects compare
TL;DR
vibe-idx is a workspace, but one app imports another app’s source by relative path, which is the case a shared package solves. fud has the cleanest direction of all three without any packages. antre’s backend reaches into the frontend’s folder, which is fine at its size.
vibe-idx
vibe-idx is a pnpm workspace like T3 Code, with three apps: feeds, pipeline, and web. That part is right. Each app has its own package.json and its own dependencies.
But the pipeline imports types from the web app’s source:
import type { corporateActionSchema } from '../../web/src/data';Four files in apps/pipeline do this. The schemas in apps/web/src/data.ts are a contract between the pipeline that writes JSON and the web app that reads it. Today the contract lives inside one consumer. apps/pipeline/package.json does not list the web app, so nothing records the edge. If you used a tool like T3 Code’s ci-changes.cjs, a change to data.ts would not rerun the pipeline’s tests. This is the case T3 Code solves with packages/contracts.
workers/dispatch sits outside the workspace. It has a wrangler.jsonc and one JavaScript file, but no package.json, and pnpm-workspace.yaml lists only apps/*. For a cron worker with no dependencies, that is a fair choice.
fud
fud is a single app with three source folders: src/ for SvelteKit, worker/ for the API Worker, and domain/ for the business rules. The worker imports from domain:
import { groups } from "../domain/groups";
import { readGmailSettings } from "../domain/gmail";
import { readPreferencesInput } from "../domain/preferences";And domain/ imports only its own files and zod. Nothing in it reaches into src/ or worker/. That is the same one-way rule T3 Code follows, kept with folders instead of packages. The AGENTS.md rule “Keep Parser functions pure” backs it up. fud does not need a package here. One tsconfig.json covers all three folders, and one app deploys them. What fud lacks is a check. Today the direction holds because you remember it.
antre
antre keeps its Convex backend in convex/ next to the SvelteKit app in src/. The backend imports helpers from the frontend’s lib folder:
import { isoDay, slots } from "../src/lib/time";
import { addLocalDays, zoned } from "../src/lib/timezone";
import { DEFAULT_TOPIC_TAG, topicTagSchema } from "../src/lib/topicTag";The imported modules (time, timezone, linkedin, topicTag) are pure helpers that both sides need. That is T3 Code’s “more than one runtime needs it” case at a small scale. A package would be overkill. Moving these four files to a folder such as src/lib/shared/ would make the rule visible, so nobody adds a Svelte import to a file that Convex loads.
When the T3 Code approach is overkill
T3 Code has about thirty packages because it ships to five runtimes and lets outside teams add providers. None of your projects has that problem. A folder with a one-way rule, plus one lint rule to enforce it, gets you most of the benefit. Reach for a package when two apps in one workspace need the same code, as in vibe-idx.
Try it
TL;DR
Move vibe-idx’s shared schemas into a real package, add a lint rule that keeps fud’s domain folder pure, and try to break that rule on purpose to see it fail.
- Give vibe-idx a contracts package. Create
packages/data/package.jsonwith"name": "@vibe-idx/data","type": "module", anexportsentry"./schemas": "./src/schemas.ts", andzodas a dependency. MovecorporateActionSchemaandriskSchema(and anything they need) fromapps/web/src/data.tsintopackages/data/src/schemas.ts, and re-import them indata.ts. Addpackages/*topnpm-workspace.yaml. Add"@vibe-idx/data": "workspace:*"to bothapps/web/package.jsonandapps/pipeline/package.json, and change the four../../web/src/dataimports to@vibe-idx/data/schemas. Runpnpm install, thenpnpm --filter @vibe-idx/pipeline testandpnpm --filter @vibe-idx/web test. It worked when both pass andgrep -rn "../../web/src" apps/pipelineprints nothing. - Make fud’s direction a rule. In
fud/eslint.config.mjs, add a config block withfiles: ["domain/**/*.ts"]and ano-restricted-importsrule whosepatternsforbid../src/*,../worker/*,$lib/*, andsvelte*. Give it a message that says why, in the style of T3 Code’s forge rule: “domain/ is pure business logic; the app and the worker import it, never the reverse.” Runpnpm lint. It should pass. - Prove the rule works. Add
import { page } from "$app/state";to the top offud/domain/money.tsand runpnpm lint. You should see your message. Delete the line and run it again to confirm it passes.