Chapter 12 · Engineering culture
Hit every surface
T3 Code's checklist for changes that must work on every client, entry point, and agent tool, traced through one real pull request, plus the one-way doors hiding in fud and antre.
When you build alone, a feature is done when it works where you tried it. You clicked the button, the right thing happened, you shipped. T3 Code has a web app, a desktop app, a mobile app, a command palette, keyboard shortcuts, a settings screen, and a set of tools that agents call through MCP. A feature that works in one of those and is missing from the others is, in the maintainers’ words, the most common defect in the repo.
This chapter reads the checklist T3 Code uses against that defect, then follows one real pull request, pinning and archiving projects, through every place it had to land. Then it applies the same checklist to fud and antre, which also have a web app and an MCP server, and finds an action in each that you can take but not cleanly undo.
The checklist
TL;DR
Before calling a change done, T3 Code’s agent file asks you to walk eight places a feature might also need to exist and say which ones applied. Two entries matter most for a solo app with an MCP server. Can an agent reach the feature, and can you undo what you just added?
The “Hit every surface” section of AGENTS.md opens with a claim. “The most common defect in this repo is a change that works on the path you tested and is missing everywhere else.” It then asks the agent to “walk this list and say which entries applied.” The list has eight entries:
| Entry | What it asks |
|---|---|
| Entry points | Is the behavior also reachable from Settings, the command palette, and a keybinding? |
| Clients | Does it work on web, desktop, and mobile? Shared logic goes in packages/client-runtime. |
| Providers | Does each provider adapter support it, or say it doesn’t? |
| Agents | Can an agent reach it through MCP tools? |
| Contracts | Is anything crossing the wire typed in packages/contracts? |
| Reverse states | Is there a way out, and a way to see the state? |
| Connection modes | Does it work locally, over the relay, and through a tunnel? |
| Docs | Does existing guidance become wrong? |
Two entries carry a reason worth reading in full. The Agents entry says “A capability a user can trigger is usually one an agent should reach through MCP tools, and scheduled tasks run the same paths. That only works when it is a service method, not handler code.” If the logic lives inside a button’s click handler, the MCP tool has to copy it, and the two copies drift. If the logic lives in a service on the server, the button and the tool both call it.
The Reverse states entry is short. “If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug.” It names three parts. You need the way in, the way out, and a way to see which state you are in.
The section asks for an answer, not a reading. “Say which entries applied” means the agent’s final report has to name them, so a reviewer can see what was considered and what was skipped.
One feature, followed through every surface
TL;DR
The pin-and-archive pull request touched 68 files. It added two fields to a shared schema, one database migration, and one service path. Every client, the command palette, the keyboard, settings, mobile, and the agent tool then reused those, so most of the work was wiring.
PR #26154, opened by Theo, starts with “Projects pile up. There was no way to keep favorites at the top of the project pickers or to hide projects you are done with.” Pinning puts a project first in pickers. Archiving hides it. The PR touched 68 files: 30 in web, 14 in the server, 14 in mobile, seven in shared packages, two user docs, and one eval case. Here is the path in the order data flows.
The contract and the database
Everything that crosses the wire is described by a schema in packages/contracts. The update payload for a project gained two optional booleans:
export const ProjectUpdatePayload = Schema.Struct({
title: Schema.optional(TrimmedNonEmptyString),
workspaceRoot: Schema.optional(TrimmedNonEmptyString),
// ...
scripts: Schema.optional(Schema.Array(ProjectScript)),
pinned: Schema.optional(Schema.Boolean),
archived: Schema.optional(
Schema.Boolean.annotate({
description: "Threads and files are kept.",
}),
),
});The project record gained pinnedAt and archivedAt timestamps, and the server gained a migration that adds two nullable columns. Its only comment is “Null means unpinned and not archived.” There was no new API endpoint. The PR reused the existing project.update mutation and event.
That description on archived matters later. It is the text a model reads when it decides how to archive.
The agent tool gets it for free
The MCP tool for updating a project builds its parameters by spreading the same payload:
const ProjectUpdateTool = Tool.make("t3_project_update", {
...shared,
description: "Update one project's settings. Omitted fields are unchanged.",
parameters: Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
projectId: ProjectId,
...ProjectUpdatePayload.fields,
}),
}).annotate(Tool.Destructive, true);Its handler is a few lines that call ProjectService.update. Client mutations reach the same method through ProjectMutation.ts. So the PR description covers agents in one sentence. “t3_project_update gets pinned and archived from the shared payload.” Adding two schema fields gave agents the feature with no tool code at all. This is the Agents entry of the checklist working as designed.
It was not quite free. The tool’s schema is text that every agent turn receives, and T3 Code measures that text in CI. The PR grew mcpToolListJsonBytes from 46,007 to 46,109 bytes in agentContextBudget.json, and it carries the agent-context-increase label that a maintainer adds to approve growth.
One rule for every client
A project can exist on several machines, one record per machine. What if one machine says pinned and another does not? The answer lives once, in packages/client-runtime, which web and mobile share:
pinned: members.some((member) => member.project.pinnedAt != null),
archived: members.every((member) => member.project.archivedAt != null),Pinned if any record is pinned. Archived only if every record is. The PR states the intent. “If records still disagree, the project stays visible.” Both rules fail toward showing the project, so a half-finished write never makes a project vanish. A second helper, pickableProjectGroups, drops archived groups and puts pinned ones first. The web command palette and sidebar call it, and so do mobile’s home and new-task screens.
Entry points on web
On web, the PR made pinning reachable from three project lists, a sidebar folder’s right-click menu, the command palette, a keyboard shortcut, and Settings. A shared ProjectPinButton appears on project rows in the new-thread picker, the command palette results, and the sidebar filter. The command palette also gets a named action for the current project:
if (currentProjectGroup && canPinProjectGroup(currentProjectGroup)) {
actionItems.push({
kind: "action",
value: "action:project-pin",
searchTerms: ["pin", "unpin", "project", "favorite"],
title: currentProjectGroup.pinned ? "Unpin project" : "Pin project",
description: currentProjectGroup.displayName,
icon: currentProjectGroup.pinned ? (
<PinOffIcon className={ITEM_ICON_CLASS} />
) : (
<PinIcon className={ITEM_ICON_CLASS} />
),
run: async () => toggleProjectGroupPin(currentProjectGroup),
});
}The title flips between “Pin project” and “Unpin project”, so the palette offers the way in and the way out under one entry. The keyboard reuses an existing shortcut. isProjectPinShortcut in ProjectPinButton.tsx checks for the thread.pin command (mod+shift+p by default), so the key that pins a thread also pins the highlighted project in a project list. The PR’s verification notes a bug this caught. The thread shortcut “used to swallow that key on drafts,” and now leaves it to an open project list. Archive lives in Settings → Projects, with Unarchive on an archived project’s page.
Mobile, older servers, docs, and evals
Mobile has its own navigation, so it gets its own screen. The Organize section of the project settings screen has a Pin switch and an Archive row whose label flips to “Unarchive project”.
Users may run a new client against an older server that would silently drop the new fields. So the server advertises a capability, and contracts/src/environment.ts documents it with “Server keeps pinned and archived from project.update. Older servers drop them.” Web and mobile both hide the controls unless every machine holding the project reports the flag. This is likely the Connection modes entry at work. A client connected to a remote machine or through the relay can reach an install older than itself.
The user guide got a section, Pin and archive projects, that says where the controls are and that archiving keeps threads and files. The eval suite got one case, archive-project-not-delete, in projects.ndjson. It asks a model to archive a project and fails if the model calls t3_project_delete instead. The PR reports it passing 2 of 2 on GPT-6.1 Sol and says plainly that Claude models were not run.
The PR also lists what it did not test, “mobile on a device (typechecked, with unit tests for the shared logic), and a second live machine.” Hitting every surface does not mean pretending you verified every surface. It means naming each one and saying what you did there.
Reverse states, and the way to see them
TL;DR
Every action in the pin-and-archive change has an undo, and the archived state stays visible in one place so you can find your way back. The PR also cut a feature because it would have made a count on screen wrong.
Look at the pull request again through the Reverse states entry alone.
The way in and the way out exist on every surface. Pin and unpin share one button and one palette entry. Archive and unarchive share one row on mobile and one button in web settings. The agent tool takes archived: false the same way it takes true.
The way to see the state is the subtle part. An archived project leaves every picker, which is the point. If it left every screen, you could never find it to unarchive it. The PR keeps archived projects in the Settings project picker, “marked Archived, with Unarchive on their page.” There is also an implicit way out. “Adding the same folder again (palette, mobile, desktop open-folder) also unarchives it.” A user who forgot they archived a project and re-adds it gets it back, instead of an error or a duplicate.
The PR also shows when a reverse state argues against a feature. Under “Smaller than #15990,” it says “Archive does not hide threads. Older settled threads are counted on the server in pages, so hiding them would make the ‘N settled’ count lie.” This suggests the maintainers checked whether every place that shows the state would still tell the truth, and dropped the part that would not.
Names have to agree too
A way out only helps if its label is honest. docs/internals/consistency-action-names.md proposes a rule for that. It says “a command’s visible label, tooltip, menu wording and accessible name describe the same action on the same object.” It came with PR #12865, where message copy buttons told screen readers “Copy link” while they copied the message text. The fix renamed both the tooltip and the accessible name to “Copy message”. It is the same checklist idea applied to words. A screen reader is one more surface, and the same action must mean the same thing there.
How fud and antre compare
TL;DR
Fud and antre already route their web app and their MCP tools through the same backend functions, which is the hard part. Fud’s category changes even have undo on both surfaces. Each app still has an action whose undo is missing or incomplete.
Both of your apps have more than one surface. Fud has a web app that installs as a PWA, push alerts, and an MCP server for AI clients. Antre has a web app and an MCP server.
What already works
Fud’s category changes go through one class on both surfaces. When you change a Payee’s Group on the web, setPayeeGroup prepares and applies a batch through GroupChanges. The MCP tools prepare_group_changes and apply_group_changes use the same class. And the reverse state exists on both:
tool(
"undo_group_changes",
"Undo a saved batch from this AI connection only if no categories have changed since. Requires write permission; My accounts cannot change. Conflicts leave every category unchanged.",
batchInput,
async (input) => groupBatch.parse(await changes.undo(batchInput.parse(input).id, owner)),
false,
);The web route /ai/undo in worker/index.ts calls the same undo. That is T3’s “service method, not handler code” rule, in a Cloudflare Worker. CONTEXT.md also records the kind of per-surface decision that T3’s Providers entry asks for. “MCP cannot change My accounts, amounts, Transaction provenance, preferences, Gmail, Alerts or Repeating Payment ends.” A surface that deliberately lacks a feature, with the reason written down, is a decision and not a miss.
Antre’s MCP handlers are thin in the same way. The unqueue tool calls api.posts.unqueue with source: "agent", and the web form action in postActions.ts calls the same Convex mutation.
A one-way door in fud
Ending a Repeating Payment is the action with no way back. The button in EndButton.svelte asks for a second tap, which guards against slips. The server writes a row:
async endRepeatingPayment({
counterparty,
currency,
lastPaidAt,
}: {
counterparty: string;
currency: string;
lastPaidAt: number;
}) {
await this.db
.prepare(
"INSERT INTO repeatingPaymentEnds (userId, name, currency, lastPaidAt) VALUES (?, ?, ?, ?) ON CONFLICT (userId, name, currency) DO UPDATE SET lastPaidAt = MAX(repeatingPaymentEnds.lastPaidAt, excluded.lastPaidAt)",
)
.bind(this.userId, payeeKey(counterparty), currency, lastPaidAt)
.run();
}Nothing in the repo deletes from repeatingPaymentEnds. The MAX in the upsert means even the stored timestamp only moves forward. CONTEXT.md describes the only way back. “A later debit that forms a new match brings it back.” If you end your rent by mistake, it disappears from the Repeating Payments list and its monthly total until next month’s charge arrives. There is also no screen that lists what you have ended. In T3’s terms, the way in exists, and the way out and the way to see it are both missing.
A door that doesn’t close the same way in antre
Antre has an unqueue for every queue, so at first glance the reverse state exists. Read what unqueue does after it moves the post back to drafts. It calls compactAfter, which, under the “Fill gaps” setting, moves later unpinned posts earlier into the freed slots:
export async function compactAfter(ctx: MutationCtx, removedAt: number) {
const settings = await readSettings(ctx);
if (settings.gapPolicy !== "autoFill") return;
const posts = await schedulingPosts(ctx);
const movable = posts
.filter(
(post) =>
post.status.kind === "queued" &&
post.pinned === false &&
post.status.at > removedAt,
)
.sort(byTime);Queuing the post again calls nextSlot from now, which finds the next free slot. Reading the two functions together suggests the post lands after the posts that moved up, not back where it was. So queue, unqueue, queue does not return you to the starting schedule, and the other posts’ times have changed without you touching them.
That may be the behavior you want. The setting’s label in settings/+page.svelte, though, sits under the heading “When a post can’t go out” and says “Later posts move up into the next free slots.” It describes failures. The code also runs it on a manual unqueue, on Publish now, and on delete. That gap between the label and the code is the Docs entry of T3’s checklist. The agent calling unqueue through MCP gets no warning either, since the tool description says only “Return a queued post to drafts”.
When the checklist is overkill
T3 Code has three clients, seven providers, and three connection modes, so its checklist has eight entries. Fud and antre have one web client each and no providers to adapt. For you, the useful entries are Entry points, Agents, Reverse states, and Docs. Capability flags for older servers solve a problem you do not have, since you deploy one server and one client together.
Try it
TL;DR
Give fud a way to see and undo ended Repeating Payments, make antre’s unqueue either reversible or honest about what it does, and add a short surface checklist to your agent files.
-
Add the way out and the way to see it in fud. In
worker/ledger.ts, add arestoreRepeatingPaymentmethod that deletes the matching row fromrepeatingPaymentEndsfor the Owner. Expose it on a new route next to/repeating-endinworker/index.ts. In the month screen, list the ended payments for the current month with a “Restore” action next to each, using the sameEndButtonpattern for the label. Add a case totests/ledger.test.tsthat ends a payment, restores it, and checks that it appears again in the month query. You are done when you can end your rent by mistake and get it back without waiting for next month’s charge. -
Make antre’s unqueue honest. Pick one. Either change
unqueueinconvex/posts.tsso it does not callcompactAfter(the gap stays until the next queue fills it), or keep the behavior and change both the settings copy and theunqueueMCP tool description to say that later posts move up. If you change the behavior, add a test inconvex/posts.test.tsthat queues three posts, unqueues the middle one, and asserts the third post keeps its time. You are done when the code, the settings label, and the tool description all describe the same behavior. -
Put a surface checklist where your agents will read it. In fud’s
AGENTS.md, add a short “Before calling a change done” list with four entries: web and PWA, MCP tools (or a line inCONTEXT.mdsaying the tool deliberately lacks it), the undo and where the state is visible, andCONTEXT.mdorREADME.mdtext that the change makes wrong. Ask the agent to say which entries applied in its final message. You are done when the next agent change in fud ends with a report that names each entry.