CTK Advisors
AboutServicesBlogContact
Back to Blog

Navigation

  • Home
  • About
  • Services
  • Blog
  • Contact

Projects

  • Brutalist Todo
  • Shellbridge
  • Seekarc
  • Elevate Learning KB

Connect

  • Email
  • LinkedIn
  • GitHub

Legal

  • Privacy Policy
  • Terms of Service
© 2026 CTK Advisors. All rights reserved.
    1. Blog
    2. ShellBridge Postmortem: Building a Claude Code Relay to Get It Killed
    Back to Blog

    ShellBridge Postmortem: Building a Claude Code Relay to Get It Killed

    I met an Anthropic PM at re:Invent, built a Claude Code relay, and sent him a message asking him to kill it. Two months later /remote and /teleport shipped. Here's the architecture, what I learned, and why getting obsoleted was the plan.

    April 13, 2026·13 min read·Chris Knuteson
    developmentaiclaude-codecloudflare-workersdurable-objectswebsocketspostmortemarchitecture

    I got a few minutes with a Claude Code PM at a conference, connected on LinkedIn afterward, and a few weeks later sent him a DM that read, more or less: "I built the thing I wish Claude Code did natively. Please kill my product."

    Two months later, Anthropic shipped /remote and /teleport. The product is dead. That was the plan.

    This is the postmortem for ShellBridge - a privacy-first relay that let you drive Claude Code sessions on any of your dev machines from your phone, your tablet, or somebody else's laptop. I built it because I wanted to answer Claude's prompts from wherever I was, not only from the machine running the agent. Now you can do that in the native app. So ShellBridge is a static landing page pointing at this post, the worker is returning 410s on every API route, and the GitHub repo is archived at tag v1.0.0-final in case anyone ever wants to read how it worked.

    Here's what I built in between.

    The Problem I Was Solving

    Claude Code, at the time I started, was a terminal app. If you kicked off a long-running agent task on your desktop at home and then walked away, that was it - you were tethered to the machine the agent was running on. You could SSH in from your phone, sure, but:

    • Latency is awful. Every keystroke round-trips through SSH over mobile.
    • No session history. You reconnect and you're looking at live output from wherever the agent is now, not a scrollback of what it did while you were away.
    • No push. Claude asks y/n at 11pm; you find out at 8am.
    • No multi-machine view. You have three machines with agents running. SSH gives you one tab per machine and no way to fan out across them.

    The agentic loop needs a thin, responsive, notification-aware interface that outlives any one terminal session. A mobile web app that can attend to a running agent from anywhere. I wanted that experience in the native Claude Code app. Since it didn't exist yet, I built the workaround - partly because I wanted to use it, partly because showing is easier than telling, partly as a conversation-starter with the people who could ship the real thing.

    What ShellBridge Was

    Three surfaces, privacy-first by design:

    1. A daemon on each dev machine that spawned Claude Code sessions in PTYs, streamed the I/O, and held an outbound WebSocket to a Cloudflare Worker.
    2. A Cloudflare Worker that did auth, billing, and - most interestingly - hosted a Durable Object per machine that acted as a WebSocket hub between the daemon and any connected clients.
    3. A React PWA with xterm.js that connected inbound to the same Durable Object, rendered sessions live, subscribed to push notifications when the agent needed input, and let you type replies from your phone.

    Source code never touched the Worker. Terminal I/O bytes flowed through the relay; the actual files and context stayed on the dev machines. The Worker was a dumb-pipe plus an auth/routing layer.

    Architecture Deep Dive

    The interesting half of the project. If you've ever thought about building a persistent WebSocket fabric on Cloudflare, this is the shape I'd recommend.

    The PTY Problem

    Every session was a node-pty child process on the dev machine, spawned with a controlled environment and cwd. The daemon kept a Map<sessionId, PtyProcess> and fanned bytes from stdout/stderr through the WebSocket as typed SessionUpdate messages.

    The non-obvious bit: never strip or re-encode the bytes. Claude Code's UI is a full ANSI terminal with colors, cursor movement, alternate-screen mode, and occasional OSC sequences. The xterm.js client on the other end is already an ANSI interpreter. Any "helpful" processing in the middle breaks the rendering. The daemon's job is to be a hose, not a filter.

    Resize is the other surprise. When the PWA resizes its xterm instance (keyboard pops up on iOS, user rotates device), it sends a resize message with the new cols/rows. The daemon calls pty.resize() on the matching process. If you skip that, Claude Code renders at the original terminal dimensions forever and wraps weirdly on mobile.

    MachineConnection: One Durable Object Per Machine

    This is the part I'd happily build again. Durable Objects are a perfect fit for this pattern: stateful, single-instance-per-key, capable of holding WebSockets open on both sides.

     ┌──────────┐   WS out    ┌──────────────────┐   WS in   ┌──────────┐
     │  daemon  │────────────►│ MachineConnection│◄──────────│  PWA #1  │
     │  (mac-1) │             │       (DO)       │           └──────────┘
     └──────────┘             │                  │           ┌──────────┐
                              │                  │◄──────────│  PWA #2  │
                              └──────────────────┘           └──────────┘

    One DO per machine, keyed by machineId. The daemon's outbound WebSocket is the authoritative feed. Clients connect inbound, authenticate via session cookie, and subscribe to specific sessionIds they're actually viewing. The DO fans messages from the daemon only to clients subscribed to that session, which matters because a single machine might have eight concurrent agent sessions and you're looking at one of them.

    A sibling RateLimiter DO held a token bucket per user so a runaway client couldn't spam the daemon. Durable Objects' single-threaded execution model makes token-bucket state trivial - no locking, no races, just a class instance with a storage.get/put and a clock.

    The one thing to know: DO hibernation is great for persistent WebSockets, but you have to actually opt in to it (state.acceptWebSocket) and handle the serialized-then-resumed lifecycle. Otherwise you pay wall-clock for every idle connection.

    The Message Protocol

    Four message directions, each a discriminated union, all defined in a shared @gateway/shared package with Zod schemas used at every boundary:

    • DaemonMessage - machine → worker (session output, PTY exit, heartbeats)
    • WorkerToDaemonMessage - worker → machine (input bytes, resize, kill)
    • ClientMessage - PWA → worker (subscribe, input, resize)
    • WorkerToClientMessage - worker → PWA (session list, session update, needs-input signal)

    Zod at both ends is worth the typing overhead. It caught a dozen subtle protocol drifts during development (e.g. a message I "changed" in one direction but not the other, a field I renamed in the type union but forgot to migrate on the daemon side). The runtime validation failures were clearer than any TypeScript error I would have gotten.

    Auth Split: Humans vs. Daemons

    Two auth surfaces, two token types:

    • Humans log in via GitHub OAuth, get a session cookie, and use it to hit REST endpoints and upgrade a WebSocket. Standard OAuth flow, session in D1.
    • Daemons authenticate with a hashed cgw_* token. The user creates a token in the PWA, copies it once, and pastes it into the daemon's setup step. The worker stores sha256(token), so even a D1 dump doesn't leak credentials.

    Splitting these mattered because the daemon runs on the dev machine, possibly in a user-scoped launchd/systemd service, and the human is on a different device. Same user, different trust model, different rotation story.

    Cross-Platform Install Scripts

    Probably my favorite piece. The Worker served /install.sh and /install.ps1 - scripts generated at request time from templates. Paste the curl command, and the script would:

    • Detect OS (macOS, Linux, Windows)
    • Download the pre-built daemon binary for that platform
    • Drop launcher scripts into ~/.local/bin/ (or Windows PATH-equivalent)
    • Generate and register an auto-start unit: a launchd plist on macOS, a systemd --user service on Linux, a Windows Task Scheduler XML job on Windows
    • Install shell hooks into ~/.bashrc / ~/.zshrc / the PowerShell profile, wrapped in idempotent # >>> ShellBridge >>> marker comments so a re-run wouldn't double-install

    The shell hooks shadowed the claude, aider, and codex commands - the shell found the ShellBridge wrapper first, which routed the invocation through the daemon so the session was observable. Bash and zsh used type -P and whence -p respectively to locate the real binary. PowerShell used Get-Command -CommandType Application to skip its own function table and find the actual exe.

    It was more engineering than the relay itself. The lesson: cross-platform install is the real product. If it's not one command to install and one command to uninstall, nobody will try it.

    Push Notifications

    VAPID-signed Web Push from the Worker to the PWA when the daemon detected a needs_input pattern in the stream (Claude asking y/n, a permission prompt, an interactive selection). The detection was a regex pass over the PTY output on the daemon; the worker held the browser push subscription in D1 and dispatched the notification.

    iOS Safari PWA push reliability was the single worst part of the project. It works, then it doesn't, then it works again. There is no debugging surface. You get a delivery report that says "accepted" and the phone is silent. I eventually added a fallback Web Audio chime that plays when the PWA is foregrounded and detects needs_input over the live socket, which was a better UX on iOS than relying on Apple's push delivery anyway.

    What Went Well

    Durable Objects as a WebSocket hub is genuinely the right shape. One DO per resource you're fanning out from, hibernation on, Zod schemas on the protocol. I'd reach for this pattern again tomorrow.

    D1 is plenty for low-volume SaaS metadata. Users, sessions, machines, daemon tokens, push subscriptions, audit log, usage stats - all of it fit comfortably in D1 for approximately zero dollars. The whole production backend cost under the Workers Paid plan's included quota.

    Zod at every boundary paid for itself. Four message directions, one shared package, type safety on both ends plus runtime validation. I never once shipped a protocol mismatch to production.

    The monorepo plus pnpm workspaces kept the four surfaces honest. Shared types live in one place. Breaking the protocol breaks all three consumers' builds simultaneously, which is exactly what you want.

    What Went Badly

    iOS PWA WebSocket reconnection during backgrounding. Safari is aggressive about pausing PWAs, and reconnect logic that works great on desktop Chrome feels janky on iOS. I wrote a lot of exponential-backoff-with-jitter code and it was never quite right.

    Apple's Web Push delivery on iOS is unreliable enough that I don't trust it as a primary notification channel. The fallback audio chime ended up being the more honest UX.

    The billing code was larger than the relay. Stripe webhooks, subscription lifecycle, usage tiers, metered events, customer portal, dunning emails - none of it is hard individually, all of it adds up. For a side project with ambiguous commercial potential, I should have stubbed billing to "everyone's on the free tier" and revisited it when usage warranted.

    No real users to confirm the core thesis. I had a small number of free signups and no paying customers when the product got obsoleted. I can tell you the architecture works because I used it daily for three months on my own agents. I can't tell you whether the broader market wanted this, because the market never got to weigh in.

    Why It's Now Defunct

    In early 2026, Claude Code shipped /remote - drive a session from another device over an Anthropic-owned backchannel - and /teleport - hand a session between machines. That's the job ShellBridge was doing. No daemon, no relay, no OAuth app, no VAPID keys, no install scripts that generate launchd plists. It's just native.

    This is good. I wanted this outcome. Anthropic owns the agent; Anthropic should own the remote-attend surface. A third-party relay in the middle is strictly worse than first-party support: worse latency, more auth surface, more trust you have to ask users to extend, more pieces that can go wrong.

    I sent the "please kill my product" message knowing full well that if I was right about the need, a first-party version was inevitable and better. Being right and being obsoleted were the same outcome.

    Sunset Mechanics

    In case you're reading this because you actually had ShellBridge installed and your daemon just started failing:

    shellbridge uninstall --force

    Removes the config in ~/.config/shellbridge/, strips the shell hooks from your rc files, unloads the launchd/systemd service, and deletes the CLI wrapper. On Windows, the equivalent wipes the .bat/.ps1 wrappers and unregisters the scheduled task.

    On the infrastructure side, shellbridge.io is now a static landing page that serves the marketing copy and links to this post. The Worker returns HTTP 410 Gone on every /api/* route. The MachineConnection and RateLimiter Durable Objects are gone. The D1 database has been exported and deleted. The GitHub OAuth app is revoked. The Stripe webhook is deleted. VAPID keys are rotated.

    The GitHub repo is archived at tag v1.0.0-final - the last fully-working build. If you want to resurrect it, clone the tag, restore the secrets listed in .dev.vars.example, and just deploy. It'll work. It's just that you shouldn't.

    Lessons - On Building to Be Obsoleted

    The usual advice is: don't build on top of a fast-moving platform, because they'll eat your feature. That's good advice when your goal is a durable business.

    It's the wrong advice when your goal is to make the platform better, use the thing you built in the meantime, and have an interesting conversation with the people who can ship the native version.

    Three things I'd say if you're considering building a thin wrapper around an AI tool in 2026:

    1. Know which game you're playing before you start

    If you want a business, don't build a feature the platform owner will obviously ship in 12 months. Pick a problem they won't solve - specific verticals, workflow glue between tools they don't control, data they don't have access to - or make peace with being absorbed.

    If you want a conversation-starter and a working tool for yourself in the meantime, build the thin wrapper, ship it polished, send it to the team that should absorb it, and plan the sunset from day one. That's what this was.

    2. Build something usable, not just demoable

    ShellBridge wasn't a prototype. It had GitHub OAuth, billing, cross-platform installers, a legal policy stack, push notifications, admin tooling, audit logs. The difference between a polished product and a demo is whether people can actually adopt it. A demo gets ignored. A polished product, even a small one, gives the platform team something real to look at.

    If the goal is influence - "here's the UX I think you should ship" - build it to the quality bar that makes ignoring it expensive.

    3. Plan the unwind before you need it

    Archive tag, archive branch, exportable data, a reversible deploy strategy, a clean uninstall command for users, a blog post that explains what happened. I wrote the uninstall command before I had any installers to uninstall. I'm glad I did, because the shutdown took an afternoon instead of a week.

    The half-life of a side project in a fast-moving space is short. Design the teardown as a first-class feature of the product. Future-you will be grateful.


    The code is archived at v1.0.0-final if you want to read it. The Durable Object WebSocket hub pattern is genuinely reusable for any fan-out-from-one-source-to-many-clients problem, not just terminal relays. The install-script generator is worth studying if you've ever had to ship a cross-platform daemon. The auth-split between human sessions and daemon tokens is the right pattern for any tool where the control plane lives on a different device than the thing being controlled.

    The product is gone. Go use /remote and /teleport. They're better.

    Share this post