← BLOG
DevStationJuly 17, 2026

JSON-RPC and versioned schemas in a local CLI

architecturerpcmcp

Everyone who has integrated two systems knows the scene: one side changes a field in the message, the other side only finds out when something breaks in production. The problem is usually associated with services owned by different teams, but it exists anywhere two programs talk, even inside a CLI running on your machine. In DevStation, the interface and the engine talk over JSON-RPC 2.0 with versioned contracts, like two independent systems, making it possible to even use architecture tests to break the build if the interface tries to import the engine directly. In a local CLI that sounds like bureaucracy. This article is about why I did it anyway, and about what that boundary unlocks.

In the extensibility article I told how the migration to this boundary was incremental, one context at a time, with a formal review before expanding. Here I want to go down into the how: what versioning the schemas means, how events cross the boundary and where that door points.

Contracts with a version

If contract versioning is new to you, the idea is smaller than the name. A schema is the formal description of the messages the boundary accepts: which operations exist, which fields go into each request, which come back in each response and the type of each one. Versioning means recording that description with a number that follows the changes, and the deal is simple: a new field breaks nobody and the version barely moves, a removed or renamed field breaks someone, and the version goes up to warn the clients before the problem becomes a production error.

In DevStation, the contracts are OpenRPC documents, one per bounded context, and they are the source of truth: no runtime code lives next to them. Each context carries its own semantic version, and the evolution rule is additive: adding a method or a field is routine, removing or renaming is breaking and bumps that context’s version. Client code is generated from the schemas, TypeScript today, with the same path planned for other languages, and CI verifies that generation is idempotent, meaning generating twice produces exactly the same file.

What holds everything together in practice are the contract tests: the request and response JSON of every method is locked as a snapshot, and any drift fails the build. In practice, the change flow is always the same: I edit the schema, run the code generation, the client gets the new types, and the test compares each operation’s JSON against the snapshot stored in the repository. A renamed field shows up as a diff in CI before it reaches any client. Changing a contract becomes an explicit, visible and reviewable act, instead of a side effect someone discovers at runtime.

Events cross the boundary

Not everything is request and response. Provisioning a cluster takes minutes, and the interface needs to follow along without polling. For that the boundary uses the second half of JSON-RPC, notifications: the UI starts the provisioning, gets an execution id right away, and the engine pushes progress and completion as messages that expect no reply. It is the same pattern the Language Server Protocol has used inside editors for years, so there is nothing exotic here, just a mature pattern applied in an unusual place.

The same channel serves warnings that are not progress: when the session expires, the engine is the one who says so, and the interface reacts by asking for the password again, without keeping any clock of its own. The pattern repeats for any long-running work: the call returns immediately with an identifier, the work continues in the background, and whoever cares follows along through the notifications.

What the isolation pays for today

The immediate consequence is that the TUI, DevStation’s interface that runs inside the terminal (the same style as tools like htop or lazygit), became a client, not the engine’s owner. MCP is another client of the same boundary, with operation parity as a requirement, so an AI agent operates the system through the same contracts the human interface uses. And swapping interfaces stopped being a rewrite: I once catalogued an alternative Go TUI in the roadmap, in case the bet on the current one did not hold. The bet held and the idea is archived, but what matters is the price it would have had: a rewrite of the UI layer, with the engine untouched.

The door to the future

It has been recorded in the roadmap, since the boundary’s spec, what it unlocks beyond swapping UIs: a desktop app, a web UI, the headless core (the engine with no interface at all) consumed by integrations. Today’s transport is stdio, the standard input and output of a local subprocess, but the transport interface was designed to receive others, and that is where the long-term vision lives: the same contract over a socket turns the CLI into a backend service. From there, an internal SaaS for a small team becomes mostly a matter of transport and authorization, and a public multi-tenant service, a single instance serving several isolated customers, is the next step on the same ladder. The project’s vision was always to start at the homelab and grow toward small teams, and the hexagonal architecture is what keeps those steps reachable without rewriting business rules.

None of this is a promise. They are options, and options may never be exercised. What I can state is the cost of keeping them: an envelope, versioned contracts and the discipline of not letting an import cross the boundary.

Back to the bureaucracy from the opening. In a local CLI, JSON-RPC with versioned schemas looks like unnecessary weight, and it would be, if the goal were only today’s CLI. What I bought with that weight is the freedom of not having to decide the product’s final shape now. It is too early to say which step of the ladder will be taken, but climbing any of them will not start with a rewrite.

References