Chapter 5 · Architecture
Contracts: one schema for the wire
How T3 Code describes every message between server and client once, in one package, and why that single description is what keeps four apps in agreement.
In a solo project the server and the client usually ship together, so they can’t disagree for long. If you rename a field, you fix both sides in the same commit and deploy them in the same minute. T3 Code doesn’t have that luxury. A desktop app from last month can talk to a server from today. A phone can connect to a laptop that hasn’t updated in a week. This chapter shows how the maintainers keep those pieces in agreement. They describe the wire once, in packages/contracts, and make everything else derive from that description.
What a contract is
TL;DR
A contract is the one place that says what a message looks like. The server, the web app, the desktop app, and the phone app all read the same file, so they can’t quietly disagree.
A contract is a shared description of a message: its fields, their types, and the rules they must follow. T3 Code keeps every contract in one workspace package, packages/contracts. The architecture overview calls it “the boundary between independently versioned clients and servers” (overview.md). AGENTS.md lists it among the surfaces every change must consider. “Anything crossing the wire is typed in packages/contracts. Change the schema and the server, web, mobile, and desktop all follow” (AGENTS.md).
The package is large, about 31,000 lines across files like rpc.ts, server.ts, keybindings.ts, and orchestrationV2.ts. Its package.json has exactly one dependency, effect. That is a deliberate shape. A contracts package that imported server code or React would drag those into every consumer. This one only knows how to describe data.
The schemas are written with effect/Schema, part of the Effect library. If you know zod, the idea is the same. You build a value that describes a shape. From that value you get a TypeScript type for free, plus a decoder that checks untrusted input at runtime. The difference is mostly vocabulary. Schema.Struct is zod’s z.object, and Schema.optional is .optional().
Follow one contract through the system
TL;DR
Saving a keyboard shortcut touches five places: the shape of the request, the named method, the server code that handles it, the permission it needs, and the client call. Every one of them points back at the same definition, so the compiler catches a mismatch.
The best way to see a contract work is to follow a small one. Saving a custom keyboard shortcut in Settings is small enough to trace in a few minutes and touches every layer.
The shape of the request
The input is a struct in server.ts.
export const ServerUpsertKeybindingInput = Schema.Struct({
key: KeybindingValue,
command: KeybindingCommand,
when: Schema.optional(KeybindingWhen),
replace: Schema.optional(ServerUpsertKeybindingReplaceTarget),
});
export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type;The last line is the pattern you will see everywhere in this package. The TypeScript type is derived from the schema with typeof ... .Type. Nobody writes the type by hand, so the type and the runtime check can’t drift apart. The fields are themselves schemas with rules attached. keybindings.ts caps the length of a when clause and the number of keybindings a config may hold. A client that sends a 10,000-character condition is rejected at the door, not deep inside the server.
The named method
Next, rpc.ts turns the shape into a remote procedure: a name, an input, a success shape, and the errors it may return.
const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {
payload: ServerUpsertKeybindingInput,
success: ServerUpsertKeybindingResult,
error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]),
});The method name is a constant, "server.upsertKeybinding", kept in one WS_METHODS object (line 514). Notice that the errors are part of the contract too. The client knows ahead of time that this call can fail because the keybindings file is broken or because the caller lacks permission. It doesn’t have to guess from an HTTP status code. Every procedure is then collected into one group, WsRpcGroup (line 2067), which is the whole API in a single value.
The server handler
On the server, ws.ts builds its handlers with ServerWsRpcGroup.of({ ... }). That call is typed from the group, so a handler with the wrong input type, or a missing handler, fails typecheck. The keybinding handler is three lines of real work.
[WS_METHODS.serverUpsertKeybinding]:
/* Request instrumentation already owns the span; keep this helper in that context. */
Effect.fnUntraced(function* (rule) {
return yield* keybindings.upsertKeybindingRule(rule);
}),rule arrives already decoded. The handler doesn’t call a validator, because the RPC layer decoded the payload against ServerUpsertKeybindingInput before the handler ran. This is boundary discipline in practice. You check untrusted data once, where it enters, and trust the types after that.
The permission it needs
The same method name appears in RpcAuthorization.ts, mapped to the settings:write scope. The interesting line is the end of that table.
} as const satisfies Readonly<Record<WsRpcMethod, AuthEnvironmentScope>>;Record<WsRpcMethod, ...> means “one entry for every method in the contract”. If someone adds a new RPC and forgets to decide who may call it, the server doesn’t compile. Security here doesn’t depend on reviewers remembering. The type system makes forgetting impossible. The same trick shows up in RpcInstrumentation.ts and FeatureUsage.ts, so every method also gets a tracing category and an analytics decision.
The client call
On the client side, packages/client-runtime, shared by web, desktop, and mobile, wraps the method in a command.
upsertKeybinding: createEnvironmentRpcCommand(runtime, {
label: "environment-data:server:upsert-keybinding",
tag: WS_METHODS.serverUpsertKeybinding,
scheduler: configScheduler,
concurrency: configConcurrency,
}),The command is keyed by the same WS_METHODS constant, so its input and result types come from the contract. The settings screen calls it as upsertKeybinding({ environmentId, input: payload }) (KeybindingsSettings.tsx). If the contract gains a required field, that line turns red in the editor.
Five files, one definition. Rename a field in server.ts and the compiler walks you through every place that needs to change.
Old and new versions talking to each other
TL;DR
A new server can meet an old app, and the other way round. So the schemas are written to skip things they don’t recognize instead of crashing, and stored history must still read correctly years later.
A shared schema solves agreement only when everyone runs the same version. T3 Code can’t assume that. So baseSchemas.ts has a family of helpers whose names say what they are for: ForwardCompatibleOptional, ForwardCompatibleNullable, ForwardCompatibleArray, and ForwardCompatibleUnion (baseSchemas.ts). The contracts package uses them about 80 times.
The idea is simple. When an older client decodes a message from a newer server, it may meet a value it has never heard of, such as a new kind of event in a list. A strict decoder would reject the whole message. A forward-compatible one keeps the parts it understands. An optional field that fails to decode becomes “absent”. A list item it can’t read is dropped. An unknown member of a tagged union becomes a placeholder object. The doc comments are precise about the limits. ForwardCompatibleArray’s comment says to prefer the union version for tagged unions “so malformed known members still fail” (line 97). Being lenient with the unknown should not mean being lenient with the broken.
Compatibility also reaches into storage. The overview warns that “retained events must remain decodable on replay. Changing a schema affects old environments at startup as well as live RPC traffic” (overview.md). A schema change is a change to every row already written with the old shape. The same document describes keeping legacy wire fields alive for pull request linking because “older clients still read them” (overview.md).
Small rules that protect the schemas
TL;DR
They also guard how schemas get used. Decoders must be built once at the top of a file, not on every call. Unused exports get flagged, but schemas count as used when only their type is used. Even trimming a string is tuned for the phone.
Three details show how much care goes into a package that everything depends on.
Compile once. A schema decoder is a function built from a schema. Building it inside a hot function rebuilds it on every call. The lint rule no-inline-schema-compile catches it, and its message tells you the fix.
const messageHigh = (method: string) =>
`Hoist Schema.${method}(...) to module scope: both the inline schema literal and the compiled function are rebuilt on every call. Move the compiled function to a module-level const.`;This was the first rule in their custom lint plugin. The commit that scaffolded the plugin, #2603, added it.
Dead code that isn’t dead. The repo runs knip to find unused exports. Schemas confuse it, because code often imports a schema only for its type (typeof X.Type), which looks like an unused value. scripts/knip-schemas.ts is a knip preprocessor that asks the TypeScript compiler which reported exports are really Effect schemas and treats them as used. It came in with #10171, titled “enforce runtime exports while allowing types and schemas”. The tool got taught about the codebase’s conventions instead of everyone learning to ignore its warnings.
Mind the slowest client. Even TrimmedString has a comment about performance on the phone.
// Hermes copies the string on every `trim()`, even when nothing changes.
// Returning the original keeps repeated IDs shared after decode.
const trim = (value: string) => {
const trimmed = value.trim();
return trimmed.length === value.length ? value : trimmed;
};Hermes is the JavaScript engine React Native uses. Because the contracts run on the phone too, a detail like string copying in a decoder is worth a branch.
How your projects handle the wire
TL;DR
You already check data where it comes in, which is the hard part. The gap is that some shapes are written twice, once for the database and once for the AI tools, and nothing forces the two copies to agree.
You already practice the core habit. In fud, the Worker validates every request body at the edge before touching the ledger, with a z.strictObject per route and a human error message on failure (worker/index.ts). That is the same boundary rule T3 Code follows.
fud’s record is a more interesting case. The type RecordData is written by hand in src/lib/record-types.ts from domain types, and the zod schema that reads it is annotated against that type.
// Object schemas strip unrecognized fields before serializing Worker data into HTML.
export const recordSchema: z.ZodType<RecordData> = z.object({The annotation z.ZodType<RecordData> makes TypeScript check that the schema produces a RecordData. That gives you most of what T3 Code gets from deriving types from schemas. The direction is reversed, though, and it matters a little. In T3 Code the schema is the source and the type follows. In fud the type is the source and the schema has to keep up. Adding an optional field to RecordData won’t break the build, so the schema can silently strip the new field. The comment on that line says stripping is intended, which makes it hard to notice.
antre shows the riskier version of the same gap. Its Convex schema defines the shape of a post part once, with Convex validators (convex/schema.ts). The MCP endpoint then defines it again with zod (src/routes/mcp/+server.ts).
export const part = v.object({
text: v.string(),
imageId: v.optional(v.id("_storage")),
});The two definitions agree today. Convex will still reject a bad call, so drift can’t corrupt data. What drift can do is make the AI tool accept input that Convex then refuses, or refuse input Convex would take. The user sees a confusing error from a tool that claimed to accept their input. The extra rules in the zod copy, like the 500-character limit per part, live only on the MCP path. Convex functions themselves already work the way T3 Code’s contracts do. The validators you declare in args generate the typed api object your Svelte code calls, so the database side of antre already has a single source.
Should you copy T3 Code’s whole approach? No. A separate contracts package earns its keep when independently released programs share a wire. fud’s Worker and SvelteKit app deploy together, and antre’s clients are your own UI and one MCP route. For you the lesson is smaller and still worth having. Make one definition the source and derive the rest, and check untrusted data once at the edge.
Try it
TL;DR
Make antre’s AI tool reuse the database’s definition of a post part, and flip fud’s record so the schema is the source and the type comes from it.
- One source for antre’s post parts. In
src/routes/mcp/+server.ts, thepartsandmetazod schemas restatepartandmetafromconvex/schema.ts. Move the extra rules (non-blank text, 500 characters) into one small module, for examplesrc/lib/postParts.ts, and have both the MCP zod schema and a Convex-side check call it. To check that it worked, change the limit in that one module to 400 and confirm that both the MCP tool and a direct Convex mutation reject a 450-character part. - Let fud’s schema own the type. In
src/lib/server/record.ts, exporttype RecordData = z.infer<typeof recordSchema>and delete the hand-writtenRecordDatainsrc/lib/record-types.ts, keeping the other exports. Runpnpm typecheck. Each type error it reports is a place where the hand-written type and the schema already disagreed. Fix each one by changing the schema, not the type. You’re done whenpnpm typecheckpasses and a new optional field in the schema shows up in the Svelte components’ types without any other edit. - Make a table that can’t be incomplete. fud’s Worker routes requests with a chain of
if (url.pathname === ...)checks. Pick one group of routes and put them in an object typedRecord<RouteName, Handler>, the way T3 Code types its authorization table. Then add a new name toRouteNamewithout a handler and confirm thatpnpm typecheckfails.