← Back to Too Good to Share

Too Good to Share / Series

CCSwitchboard

2. How it works

Five pieces, all built and live. I will name them plainly first, then explain the mechanics that hold them together. This is the long section.

The five pieces

1. The relay. Plain PHP and SQLite, sitting on dabblelabs.uk. It is the hub, and it is deliberately dumb: a job queue, repo locks, results, per-thread sessions and the streamed output, all in one small SQLite file behind a handful of PHP endpoints. No framework. It had no auth at all to begin with; a shared-token check came later, running in a grace mode I will come back to. Everything else in the system polls this.

2. The agent. A C# console app on the VM, agent/CcswAgent, built on .NET 10 and now living in the system tray. It polls the relay every couple of seconds, and when it sees a job it spawns a headless Claude Code run for it and streams that run's output back to the relay line by line. It runs four workers in one process, so several jobs can be in flight, and it sends a heartbeat every thirty seconds so the relay knows it is alive. It is C# because my existing usage app is already C# and WPF, so it was the path of least resistance.

3. The feed. This is the terminal, except it is not a terminal. feed.php?job_id=X renders a live Claude Code run as styled HTML, polling for new output roughly once a second. It is not xterm. It is a structured view of the run's stream-json events: tool calls as cards, code as code blocks, thinking collapsed away. There is a reason it ended up as HTML rather than a real terminal, and it is a good story, so it is further down.

4. The browser extension. An MV3 extension, running in Brave. This is the keystone, and the fiddliest piece by a distance. It reads Claude.ai's dispatch blocks straight from the page, sends them to the relay, and types results and wake prompts back into the chat. It auto-clicks the tool-permission dialog so a woken run is not left blocked waiting for me. It re-injects itself into open tabs on reload, so I do not have to babysit it.

5. The popup. A small C# WPF tray app, this one on the host rather than the VM. When a job finishes it shows a notification in the top-right corner; click it and it raises Brave and focuses the exact tab that dispatched the job. It only pops for jobs marked final, so intermediate steps stay quiet.

The mechanics

The dispatch block. Claude.ai fires a job by writing a fenced code block tagged ccsw containing one JSON object. A real one looks like this:

{"name":"Status Truth","thread":"CCSwitchboard","model":"sonnet","cwd":"V:/ccswitchboard","summary":"make the relay the single source of truth for all job UI state","prompt":"In browser-extension/: make the relay the single source of truth for all job UI state. Toolbar pills and menu LEDs must get status by polling the relay, never by relying on a one-shot message to the owning tab..."}

The fields are what you would expect: a two-word name, the thread it belongs to, the model, a short summary shown on hover, the working directory, and the prompt itself. A few flags change the behaviour. continue resumes the existing Claude Code session for that thread and repo instead of starting fresh. final makes it pop a notification and announce that all phases are complete. type: bash with a command runs a plain shell command with no Claude Code at all, which is how cheap checks, reads and git pushes get done. readonly marks a bash job as safe to run in parallel, because it will not touch anything under lock.

Thread identity. The thread is just a literal string that Claude.ai pastes into the block. Nothing clever detects it. Whatever Claude.ai types is the identity, and that is the entire mechanism. It sounds fragile and it is, but it is also why handing a project off from one chat thread to another works with no state to migrate: the new thread simply keeps using the same name string, and as far as the relay is concerned it is the same thread. The extension maps each thread name to the browser tab it is currently living in, and refreshes that mapping constantly so it never goes stale.

The repo lock. This is the load-bearing concurrency guard. Two headless Claude Code runs editing the same repo at once would corrupt each other's work, so the relay will not allow it. When a job comes in, the relay works out which repo it touches from its working directory and takes a lock inside a BEGIN IMMEDIATE transaction before it will create the job. A second job aimed at a locked repo gets a 409 and is dropped. It can lock several repos at once, all or nothing, for a job that legitimately spans more than one. Read-only bash jobs skip the lock entirely and run in parallel, since they cannot conflict. I stress-tested it with eighty submissions fired at a single repo at the same moment: one winner, seventy-nine clean rejections, no races.

There is no queue. A dropped job is not held for later. Worth saying plainly, because it shapes everything: when the repo frees up, the relay sends a "repo free" nudge that wakes the thread, and Claude.ai has to decide again from scratch and re-fire if it still wants to. Nothing is remembered on its behalf. It re-decides.

Streaming. The agent posts each line of a run's output to the relay as it arrives, into a table keyed by job and sequence number. The feed asks the relay for anything newer than the last chunk it saw, once a second. That is all the live terminal is: append on one side, poll on the other.

A live Claude Code run rendered in the feed: tool calls shown as cards and the run's output streaming in line by line
The streamed output rendered live: because Claude Code emits structured stream-json, the feed shows each tool call as its own card and the run's output builds up line by line, rather than as raw terminal text.

The wake loop. This is the clever bit, and the part that took longest to get right. The agent posts the finished result; the extension's background worker, which has been polling for it, sees it land and injects it into the correct thread's tab. Injecting text into Claude.ai's input is not as simple as setting a value, because the input is a rich editor that ignores direct writes. The extension has to focus it, insert the text through the one browser command the editor actually listens to, fire the input event, wait for the send button to become enabled, click it, then check the input really cleared and retry with backoff if it did not. There is also a courtesy gate: if I am mid-sentence typing my own message, incoming wakes queue up and only flush once the input is empty and idle, so the machine never types over me.

Sessions. The agent reads the session id out of each Claude Code run and the relay stores it against the thread-and-repo pair. When a later job carries continue, the agent resumes that session rather than starting a cold one, so a thread's runs share memory across jobs.

Self-healing. Early on, the worst failure mode was a job that hung forever and never came back, wedging its repo lock behind it. Two things fixed that. The first is a silence timeout: the agent watches the time since the last line of output, and if a run goes quiet for too long it kills the process tree and marks the job timed out. Jobs that keep streaming never trip it; genuinely hung ones die on their own. Slow jobs that need to read a large file before doing anything can raise that threshold per dispatch, so the reaping does not punish honest thinking. The second is a heartbeat reaper: since the agent pings the relay every thirty seconds, the relay can notice when a whole machine has gone quiet, mark its running jobs as lost, release their locks, and wake anything that was waiting. Between them, "job running forever" stopped being a thing that happened.

Cancel. A cancel is a POST to the relay; the agent, which polls for it, kills the run's process tree and marks the job cancelled.

Why everything polls. The relay is dumb PHP on shared hosting with no way to push. No WebSockets, no server-sent events. So every component sits in a loop asking "anything new?" on a timer. That one constraint explains the shape of the whole system, and it is the reason for all those arrows in the diagrams on the previous page.

How it came together

The relay went first, on its own, before any Claude Code was involved at all. I proved the round trip with curl: submit a job, poll for it, post a result. Once that worked, the C# agent went on top: poll the relay, spawn claude -p, stream the output back. Then the extension, in milestones, then the popup, then per-thread sessions, then the live feed.

The first closed loop is the moment the whole idea stopped being theoretical. I dispatched a throwaway job whose entire prompt was to say one nonsense word and nothing else. The run executed, said the word, and the extension typed it straight back into the chat. It did not auto-send on that first attempt, so I sent it by hand, but the loop had closed: the machine did what I asked through the full chain. The next iteration fixed the send, so it typed and sent on its own, and after that the thing was real.

Not long after, it started building itself. I hit a bug and reflexively reached for a normal terminal to fix it, then had to remind myself that avoiding exactly that was the entire point of the project. So we dispatched the fix through CCSwitchboard, and it edited its own code, committed it, and deployed. Later it learned to push itself too. Headless Claude Code hangs on the git credential prompt, but a bare bash job sails straight through the credential manager, so pushing became a type: bash job and stopped costing a Claude Code run.

Plenty did not go smoothly, and the dead ends are the honest part.

The ANSI dead end. I wanted the feed to be a real terminal with real colour. That meant running Claude Code in a pseudo-terminal so it would emit ANSI escape codes. Two walls. First, a real ConPTY dies instantly inside the VM's automation context with a DLL init failure, on so much as an echo. Second, and fatally, claude -p only ever emits pure JSON anyway; there is no ANSI to capture even if the PTY had worked. So I killed the colour dream. The silver lining is the feed you get instead: because stream-json is structured, I could render it as proper HTML with tool cards and collapsible sections, which is arguably better than coloured text would have been. This was one awful dead end at the time, and the better outcome came out of giving up on it.

The CORS wall and the User-Agent WAF. Two browser-and-host walls back to back. The extension cannot fetch the relay from the page's own context, because that is a cross-origin request the page is not allowed to make; the fix is to do the fetch from the extension's background worker, which is allowed. And it cannot set a User-Agent header from script at all, which mattered because the host's firewall returns 429 to any request with a blank one. The fix there is a declarative rule that rewrites the header on the way out. Neither is hard once you know it. Both cost an evening to diagnose.

The reverse-engineered selector. claude.ai's page has no stable, labelled "this is an assistant message" hook to grab onto, so every selector I guessed matched nothing. The trick, borrowed from how a public chat-exporter project does it, is to key off the one element that appears on Claude.ai's replies and never on mine: the thumbs-up feedback button under each answer. My own messages do not get one. That button, found live in DevTools, became the load-bearing anchor the whole extension hangs off.

There were smaller ones too. SQLite's write-ahead log throws disk I/O errors on a VirtualBox shared folder, so the relay uses the older rollback journal. Building the C# projects on the share hits phantom file-in-use locks, so they build locally and get copied over. None of these are interesting on their own, but there are a lot of them, and that is the texture of the thing.

Which brings me to the moment I stepped back and looked at what I had actually built. I had lost track of all the pollers and back-and-forths the thing does, and all the components. It was hard to describe. A collection of parts.

Laid out flat it is calmer than it feels from the inside. Four components, and everything just polls the relay in the middle. That is the whole topology: the relay is the only thing anyone talks to, and no component talks to any other component directly. That is also why it holds together at all, and why you can add or remove a piece without the rest noticing.

The lifecycle of a single job is the other view worth having: a dispatch block goes from the chat to the relay, the agent picks it up and runs Claude Code, the output streams back through the relay to the feed, and the final result travels back out to the extension, which types it into the same thread the dispatch came from. One loop, out and back.

And the concurrency picture, which is the repo lock doing its job: one repo, one running job, a queue of nothing, and a "repo free" nudge that wakes the loser to try again.