← trentontompkins.com

The Editor That Wouldn't Phone Home: hooking a webview's message bus

I wanted my editor to tell a local script the moment something happened. Just a tiny fetch to 127.0.0.1 — fire-and-forget, no big deal. The editor refused. No error, no warning, nothing in the console. The request simply evaporated. And the reason turned out to be one missing line of policy — a line I could add myself, because the file that sets it lives on my own disk.

This is the story of chasing a silently-dropped network request down into a VSCode webview's Content-Security-Policy, and what I found once I got inside: a single message bus carrying the entire conversation between the editor and its UI — including a clean, structured "you've hit your rate limit" event I could listen for directly.

The thing I was actually trying to do

I run one long, continuous Claude Code session that I never want to die. I pay for about a dozen Claude accounts, and each has its own weekly cap. My setup rotates to the next account the moment the current one is spent, so from my chair the session just keeps going — a relay race where the baton never drops.

The weak link was timing. I was detecting "this account is tapped out" by polling — a loop that woke up every so often, sniffed around for signs of a cap, and only then triggered the swap. Polling means latency: somewhere between "the cap happened" and "my script noticed" there's always a dead gap. I wanted the swap to fire the instant the cap landed, not on the next tick of a timer.

The obvious idea: have the editor's UI fetch a little local endpoint the moment it saw a cap, and let that endpoint do the rotation. That's the fetch that vanished.

To be clear about what this is and isn't. This is about keeping a session I pay for alive across accounts I pay for — a reliability hack on my own machine, not a way around anyone's limits. Every account still has its real cap; I'm just handing the baton more smoothly.

The wall: a webview is only as open as its CSP

A VSCode extension's UI is a webview — a little sandboxed browser frame. What it's allowed to do is governed by a Content-Security-Policy (CSP) that the extension hands to the frame in a <meta> tag. Whatever that policy says, goes. The webview can't override it from the inside.

Here's the policy this editor shipped (trimmed to the relevant directives):

<meta http-equiv="Content-Security-Policy" content="
  default-src 'none';
  style-src   'unsafe-inline';
  script-src  'nonce-A1b2C3d4';
  img-src     data:;
">

Read it like a bouncer's list. default-src 'none' is the fallback for any kind of request the policy doesn't explicitly mention: deny everything. Then a few narrow exceptions are granted back — inline styles, scripts carrying the right nonce, images as data URIs. That's it.

Now look for the one directive that governs fetch, XMLHttpRequest, WebSocket, EventSource — anything that opens a network connection. It's called connect-src. It isn't there. And because it isn't there, it falls through to default-src 'none', which means: no connections, to anywhere, ever.

That's the whole mystery. My fetch('http://127.0.0.1:8123/cap') wasn't failing — it was being refused before it left the building. The browser's CSP enforcement drops the request and (depending on version) logs at most a quiet line you'll never see inside a webview's detached console. No exception bubbles up to your catch. The promise just never resolves.

The tell, in hindsight, was obvious: chat still worked perfectly. All the editor's own traffic flows through the extension host (a normal Node process with no CSP), not through the sandboxed frame. Only my injected request — originating inside the webview — hit the wall. Working app, dead fetch: that combination points straight at connect-src.

The one-line fix (the reusable part)

Here's the thing about a webview's CSP: it's set in a file on your disk. An installed extension is just JavaScript and HTML sitting in a folder. The policy string is built somewhere in that code before it's stuffed into the frame. Since I own those files — they're running on my machine, for me — I can edit the policy.

So I found where the CSP string is assembled and added the missing directive:

  default-src 'none';
+ connect-src http://127.0.0.1:* http://localhost:*;
  style-src   'unsafe-inline';
  script-src  'nonce-A1b2C3d4';
  img-src     data:;

One line. connect-src now explicitly allows connections to loopback on any port, and nothing else. Reload, and the same fetch that silently died a minute ago sails through to my local server.

The general lesson: the sandbox around any webview extension you control is only as tight as the CSP string in its own source. If a request is silently disappearing, find the Content-Security-Policy meta tag the extension emits and check whether the directive you need (connect-src, img-src, font-src…) is present. Adding the directive you need is a one-line change to a file you already own. Keep loopback-only (http://127.0.0.1:*) so you're not opening the frame to the whole internet.

Inside the frame: one message bus carries everything

Loopback open, I could now talk out. But to know when to talk, I had to see what the editor's UI was receiving. Conveniently, a webview talks to its extension host through exactly one channel: window.postMessage. Everything the host wants to tell the UI — every token of streamed text, every status change, every event — arrives as a single "message" event on window.

The editor registers one listener for it. Stripped down, it looks like this:

window.addEventListener("message", (e) => {
  if (e.data.type === "from-extension") {
    this.fromHost.enqueue(e.data.message);   // hand it to the app's queue
  }
});

That's the entire firehose, in one place. Which means if I wrap it, I get to observe every message the editor receives before the app ever sees it. A few lines, injected at startup:

// Proof-of-life: a red badge that ticks up on every message from the host,
// so I can SEE the bus is live before I trust any logic hanging off it.
let __seen = 0;
const __badge = document.createElement("div");
__badge.style.cssText =
  "position:fixed;top:6px;right:6px;z-index:99999;background:#c0392b;color:#fff;" +
  "font:12px monospace;padding:2px 7px;border-radius:10px;box-shadow:0 1px 4px #0006";
document.body.appendChild(__badge);

window.addEventListener("message", (e) => {
  if (e?.data?.type === "from-extension") {
    __badge.textContent = "msgs " + (++__seen);   // visible heartbeat
    inspect(e.data.message);                       // <-- our hook
  }
}, true);   // capture phase: we run BEFORE the app's own listener

That little red counter in the corner was the moment the whole thing became real. Every time the model streamed a token or the host changed state, the number ticked. I wasn't guessing anymore — I could literally watch the editor's nervous system fire.

The payoff: a structured "you're capped" event

With the bus visible, I went looking for what a rate-limit actually looks like on the wire. I expected to have to infer it from some error string. I didn't. The editor emits an explicit, structured event:

{
  "type": "rate_limit_event",
  "rate_limit_info": {
    "status":         "allowed",
    "resetsAt":       1780560600,
    "rateLimitType":  "five_hour",
    "overageStatus":  "rejected",
    "isUsingOverage": false
  }
}

This is a gift. status: "allowed" is the healthy heartbeat — it arrives regularly, saying "you're fine, keep going." A cap is the same event with status flipped to a not-allowed value (and resetsAt telling you when the window reopens). So I don't have to tail a log file, scrape a stderr line, or poll an API. The cap announces itself, as a typed event, the instant it happens. My inspect() just watches one field:

function inspect(msg) {
  if (msg?.type !== "rate_limit_event") return;
  const info = msg.rate_limit_info || {};
  if (info.status === "allowed") return;      // healthy heartbeat — ignore
  onCapDetected(info);                         // not allowed -> we're capped
}

This is the difference the whole journey was about: event-driven beats polling. Polling asks "are we capped yet?" over and over and is always a little late. Here, the cap pushes itself to me the moment it exists. Zero latency, zero log-tailing.

Debounce, then act — carefully

One catch: events come in bursts. A cap can fire several rate_limit_events in a row as the UI settles. I don't want to trigger an account swap five times. So I debounce — collapse a flurry into a single action — and only then poke my local endpoint:

let __capTimer = null;
function onCapDetected(info) {
  clearTimeout(__capTimer);                    // collapse a burst into one shot
  __capTimer = setTimeout(() => {
    fetch("http://127.0.0.1:8123/cap", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(info),
    }).catch(() => {});                          // fire-and-forget
  }, 1500);
}

That fetch — the one that was impossible an hour earlier — now lands on a tiny local switcher. And the switcher does not blindly rotate. The webview's read on its own state can lag or stutter, so the endpoint treats the event as a hint, then verifies the account is genuinely spent before it touches anything:

@app.post("/cap")
def cap():
    # The webview SAYS we're capped. Trust, but verify — don't burn a good account.
    acct = current_account()
    if not is_truly_spent(acct):          # real check: is the weekly cap actually hit?
        log.info("cap hint but %s still has headroom — standing pat", acct.email)
        return "noop", 200                #  alive account -> do nothing

    nxt = pick_account_with_most_headroom(exclude=acct)
    if not nxt:
        log.warning("all accounts spent — nothing to rotate to")
        return "exhausted", 200

    switch_to(nxt)                        # swap credentials, hand off the baton
    log.info("rotated %s -> %s", acct.email, nxt.email)
    return "rotated", 200

The "alive account = do nothing" guard is the important bit. A spurious or duplicated event should never cost me a perfectly good account. The event tells me when to look; the guard decides whether to act.

Is this OK to do?

Worth saying plainly. I'm modifying software I run locally, for myself, to make my own paid sessions more reliable. That's the same category as a userscript, an ad-blocker, or a config tweak — changing how a program behaves on your own machine.

The line I keep on the right side of: I don't redistribute the editor's code. Claude Code is Anthropic's proprietary software under their commercial terms — so this article shares the method and my own glue scripts, never their source. The snippets above are short, illustrative excerpts used for commentary, and the CSP edit and the hook are things you apply to your own installed copy. Don't repackage someone's app; do feel free to understand the one running on your hardware.

The lesson

Two things I'll carry out of this. First: a webview is only as sandboxed as the CSP its extension ships. When a request silently disappears, don't chase ghosts — read the policy, find the missing directive, and (for software you own) add it. The sandbox is a string in a file, not a law of physics.

Second: the message bus is right there to listen to. Almost every editor UI, every Electron app, every webview funnels its host communication through one postMessage channel. Wrap that one listener and the entire event stream opens up — and more often than you'd think, the exact thing you need is already in there, neatly typed, waiting for someone to notice it. I went in to make a fetch work and came out with a structured cap signal I didn't know existed. The wall I hit turned out to be the door.

The helper scripts in this article are released under the MIT License — Copyright © 2026 Trent Tompkins. Claude Code is Anthropic's proprietary software; nothing here redistributes it.