Kickoff
refactor: align cli and mcp more with zod schemas
3rd of January 2026 — a commit message so boring you’d scroll straight past it. It turned out to matter more than anything else I shipped all year, which tells you something about how little commit messages have to do with the size of what they contain.
7da7edb1refactor: align cli and mcp more with zod schemas (#11)Here’s the setup. ACME — my effort-tracking system from last post — had grown two front doors. A CLI for me, and an MCP server for the agents (MCP being the Model Context Protocol: the standard that lets an AI call your tools rather than guess at your intentions). Two surfaces, same operations, and they were already starting to drift. A flag renamed on one side, an argument shape changed on the other. Week two of the project, and my human interface and my agent interface were quietly disagreeing about reality, the way two witnesses to the same event always are.
The Run
One definition, zero drift
The fix: one schema, defined once, as the single source of truth — every input validated against it, whether it arrived from my keyboard or from a model’s tool call. The CLI command and the MCP tool cannot drift, because they are the same definition. When an agent sends something malformed, it doesn’t get a mysterious failure; it gets a structured validation error precise enough to self-correct on the next attempt.
Sit with that contrast for a second. In August I was writing “NO EXCEPTIONS” in bold capitals, hoping. In January, a schema simply rejected anything outside the contract — no drama, no pleading, no exceptions, structurally. Constraints you used to beg for become constraints the system enforces. That’s the whole harness-engineering thesis in miniature, months before I knew the term.
I got a field demonstration of the alternative a few months later. During one AI Week, a team found two separate integrations for the same internal system: a locally installed skill and a tool served through the company’s agent gateway. They had drifted independently, down to expecting differently named environment variables for the same credential. Worse, the agent kept picking the local, weaker one over the richer authenticated tool, because nothing told it they were the same thing. Two definitions, two behaviours, one confused model. That is the whole post in a single bug.
Stretching the idea
I spent the rest of that fortnight seeing how far the idea stretched, building a full MCP toolkit template: every capability of the spec, with the same discipline throughout. The pattern that stuck was taking my prompt-era “guidance” — all those context files nudging behaviour — and reimplementing it as a typed, composable hook system: guidance with priorities, dependencies, and blocking approval gates. Rules that used to live in Markdown and fire never, now lived in code and fired deterministically. By January 14th it was feature-complete, documented, and — tellingly — done. Not abandoned: finished. Some explorations end because the question got answered, which is a sentence I did not get to write very often in this series.
The Debrief
This is the contracts stage, and it rewards the kind of person who cannot let a wrong symbol go unpunished:
On The Training Ground
The Single-Schema Test. Take one real tool your team drives with prose instructions and race a contract against the prose. About forty-five minutes, and the loser fails in a way you can screenshot.
Info
You’ll need: one internal script or CLI with a few parameters and at least one gotcha (“pass the env flag, unless…”), an agentic CLI, and any schema language you like — JSON Schema, Zod, or an MCP tool definition.
The best candidate is the script whose usage lives in tribal memory: a deploy helper, a data exporter, anything where onboarding includes the sentence “oh, and never run it without…”. Grab its README or the Slack message that passes for one.
Define the interface once, formally. Zod-flavoured example:
const deployParams = z.object({
service: z.enum(["api", "web", "worker"]),
env: z.enum(["stage", "prod"]),
version: z.string().regex(/^v\d+\.\d+\.\d+$/),
skipMigrations: z.boolean().default(false),
}).refine(p => !(p.env === "prod" && p.skipMigrations),
"skipMigrations is not allowed in prod");Notice the last line: the folklore just became a constraint. That refinement is the whole post in one line of code.
Two fresh sessions, same five requests, escalating trickiness — a normal call, a missing parameter, a wrong type, the ambiguous flag, and the folklore violation (“deploy to prod, skip migrations, we’re in a hurry”):
- Session A gets the README/prose docs and is asked to produce each call.
- Session B gets the schema and a validator to run each call through.
Score not just correctness but where each failure surfaced. Session A’s wrong calls look plausible and would fail downstream — in the deploy, at 5pm, with an audience. Session B’s get caught at the boundary with a named error, every time, alone, where a failure belongs. Same model, same intelligence; the difference is entirely the contract.
Tip
What you just proved: when prompts aren’t enough, the answer isn’t longer prompts — it’s contracts. And the schema you wrote isn’t agent-only: the same definition can validate your CLI, your CI, and your colleagues. One schema, every surface — you’ve now built the smallest possible version of it.
I once corrected a restaurant menu’s em dash, unprompted, in pen, and left before anyone could ask why.