# What survives when you replace the client

Author: Chris Chabot
Canonical URL: https://chabot.dev/blog/what-survives-when-you-replace-the-client/
Published: 2026-11-03

Replacing a browser client on a shared TypeScript package and adding a native Rust client on the protocol alone: what each reused, and where scrolling and login cut across.

An agent application looks like a chat window, but the window is doing a lot of work. It shows a transcript that grows a token at a time, tool calls moving through their lifecycle, approvals waiting on a person and compaction checkpoints, and it handles keyboard commands, settings and the user's login while it does so. If all of that lives in the presentation layer, replacing the presentation means rebuilding the product, and adding a second client means building it twice.

The agent platform I work on went through both of those changes in 2026. In March I added a short-lived native Rust desktop client beside the browser client, and in August I replaced the web client's presentation layer completely. Both clients stayed on the same gateway, but they kept very different things stable, and that difference is what decides how much a new client costs.

## Keeping the code

The web replacement could keep a great deal, because the client workspace already had a shared package underneath the old web client. That package held the protocol client, the stores, the domain types, hooks, hotkeys, commands, voice and the auth store, and the new client depended on it through the workspace and reused those layers unchanged.

What was new was the layer that draws. The presentation was composed from shadcn/ui and Vercel's AI SDK Elements, adapted where the product needed it. Those components are their authors' work. My part was the integration and the product decisions around it, starting with how the product's concepts should map onto them.

A mapping table paired the product’s concepts with the new components. A user turn became a Message and a Bubble. A shell command became a Tool containing a Terminal and a TerminalStatus. An approval became a Confirmation driven by j, k, Enter and Escape. A compaction became a Checkpoint, and the transcript sat inside a headless MessageScroller. A table like this looks like project administration, but it holds the design, because its left column is the product's vocabulary, which stays, and its right column is a component library, which can change again.

An agent implemented the first version under my direction, with me as co-author. We built the new client beside the existing one, ported transcript and settings improvements across, and then retired the old web entry point while keeping the existing dev and build aliases working.

The transcript is where "just the presentation" stops being a comfortable description. Following a stream as it grows, and getting out of the way the moment someone scrolls up to read, is behaviour people notice immediately. The new transcript had no follow engine, spacer, anchoring or pinning logic of its own. It left that to MessageScroller and kept only a check for whether a key scrolled upward and an event announcing that the user had scrolled. The decision about when the user has taken control stayed with the product, and the mechanics of scrolling moved into somebody else's component. I think that was the right thing to give away, with the understanding that your scroll behaviour now arrives in somebody else's release notes.

The new web client started with the same protocol handling, state and commands, leaving its presentation behavior as the work still to be implemented and checked.

## The address

One thing could not come through the shared package, and that was the address. A client running on a preview origin is a satellite as far as login is concerned. Auth0 allowlisted only the canonical origin and the token endpoint's CORS rules kept other origins out, so a satellite could neither run PKCE for itself nor refresh its own tokens. The obvious ways out are broad redirect allowances at the identity provider or a separate identity implementation for each origin.

I took a third route and added a first-party handoff to the shared auth code, in which the canonical origin ran the login and delivered the result. An endpoint that hands tokens to whatever address it is given would be a gift to an attacker, so most of the design went into narrowing that address. The canonical side parsed the return URL and refused it if it contained a username, password, query string or fragment, if its path was anything but the handoff path, or if its origin was not on an exact allowlist. If the user had to log in first, the pending handoff was stored and went through the same validation again when it was consumed, so having passed once wasn't enough. The satellite opened the exchange by generating a nonce and accepted tokens only with that nonce, which stopped it being handed a login it never asked for.

The tokens travelled in a URL fragment, which browsers do not send to servers and which therefore stays out of request logs, and the satellite removed it with `history.replaceState` as soon as it had read it, so it didn't linger in the address bar. The satellite then stored the tokens, reset its identity-scoped caches so that nothing from a previous identity carried over, and scheduled a refresh ahead of expiry. Since it still could not reach the token endpoint, that refresh was another trip through the canonical origin.

A client's origin is part of its contract with the identity provider, which nobody thinks of as a presentation concern until the presentation moves.

## Keeping only the protocol

The March client kept far less. GPUI is the Rust UI framework from the Zed editor, GPU-accelerated by design, and a Rust application cannot import a TypeScript store. What it could use was the gateway protocol, through a Rust SDK, one of a family of SDKs for TypeScript, Python, Rust and later Swift that came out of an early expansion with several contributors.

So the state model had to exist a second time. At the centre was one dispatch function in a gateway adapter. It took the application state and a server event, updated the state and returned whether anything needed rendering. Its match arms covered connection and authentication, project and session changes, state snapshots, turn starts, text deltas, turn completion and errors, tool calls, permission requests and questions. A heartbeat or a pong returned false, so keeping the connection alive never repainted the window.

The state tests read like a specification of what the events mean: what a permission request does to a tool call, what happens when an approval resolves, how a tool moves through its lifecycle, what gets cleaned up when a session is deleted, how an unknown event is handled and that a heartbeat causes no render. Those rules had to be stated again because a protocol shares messages and leaves each client to decide what they mean for its own state.

The runtime had to be reconciled too. Network work ran on a dedicated two-worker Tokio runtime on a background thread, and a standard-library channel carried events across to the UI, which polled about every 16 milliseconds. The native client read its server URL and token from environment variables.

## Where the line goes

Put side by side, the two changes describe two contracts with different prices. The shared TypeScript package let the replacement browser client reuse existing behavior directly. The Rust client shared the gateway protocol, but needed its own mapping from messages to native state and its own tests for those transitions.

That gives me a fairly simple rule. Anything that decides what the conversation is belongs beneath the presentation: the event vocabulary, the state of turns and tool calls, what an approval means, commands and shortcuts. Anything that decides how the conversation looks should be easy to throw away. The awkward cases are the ones like scrolling and login, which look as though they belong on one side and turn out to straddle both, and they are worth finding before you start, because they are where a replacement that looked like a reskin turns into product work.
