If you want to drive a real Firefox from Python today, your options are a bit heavy: Selenium, geckodriver, or a Playwright server. That’s fine when you want a full framework. It’s a lot when all you want is a tiny object that can open a URL, run some JavaScript, take a screenshot, and type into a box — over a single websocket, the way people drive Chrome with a raw DevTools connection.
That lightweight path used to exist for Firefox too, through the Chrome DevTools Protocol. It doesn’t anymore. So I wrote the ~700-line adapter that brings it back — firefox-cdp (MIT). One dependency (websocket-client), the rest is the standard library.
First, what it is and isn’t. This is a Python library you import — not a network proxy and not a CDP server. It does not accept incoming CDP connections from Puppeteer or other clients, and it does not restore CDP in Firefox. It gives your Python code a small, CDP-style Tab API that’s backed by Firefox’s WebDriver BiDi. If you’ve used a raw CDP Tab, this feels identical to use — but “CDP-style” describes the shape of the API, not the wire protocol underneath (which is BiDi).
What actually happened to CDP in Firefox
Firefox shipped an experimental CDP implementation back in 2021 (Firefox 86), so tools like Puppeteer could talk to it. Then Mozilla decided to stop maintaining a protocol it didn’t control and standardize on the W3C’s WebDriver BiDi instead:
- Firefox 129 (May 2024): CDP support deprecated — off by default, still opt-in via the
remote.active-protocolspref. - Firefox 141 (June 2025): CDP completely removed, pref and all. 141 is the first Firefox where you simply cannot use CDP.
So on any current Firefox, point --remote-debugging-port at it expecting CDP and the /json/* discovery endpoints return HTTP 404. The Remote Agent is still there — it just speaks WebDriver BiDi now. (Verified live on Firefox Developer Edition 153.)
This is not a tragedy, and it’s worth being precise about: BiDi isn’t “CDP with new names.” It’s a different, bidirectional, standardized protocol — a superset of what Firefox’s CDP did. The one thing that carried over is the transport shape: JSON-RPC over a single websocket. That’s the crack this tool slips through.
Why a 700-line adapter instead of “just use Playwright”
Fair question — and the answer is scope, not competition. Playwright, Selenium and Puppeteer are frameworks: driver binaries, big dependency trees, an execution model. If you’re writing end-to-end tests, use them.
But if you’re writing a small automation script or agent that already thinks in terms of a minimal Tab object, pulling in a whole framework just to get a Firefox backend is a bad trade. This library is for that case: raw websocket, one pip dependency, uses the Firefox already on the machine, and reads top to bottom as a clean reference for how BiDi actually works. A library, not an ecosystem.
The four things that will 403 or 404 you
Getting a BiDi session up has a few non-obvious traps. The adapter handles all of them; here they are so the protocol isn’t a black box:
- The endpoint has a path. The agent logs
WebDriver BiDi listening on ws://127.0.0.1:<port>— with no path. The bare root is just an HTTP server. The real websocket is atws://<port>/session. session.newis mandatory. Sendsession.new {capabilities:{}}before anybrowsingContext.*orscript.*call. Its reply carries the realbrowserVersion.- There’s an Origin allowlist.
websocket-clientsendsOrigin: http://127.0.0.1:<port>, so launch with-remote-allow-origins http://127.0.0.1:<port>or the handshake dies with 403 “incorrect Origin header” before you send a command. script.evaluatereturns a BiDi remote value, not plain JSON —{"type":"string","value":"…"}, arrays and objects as tagged shapes. A smalldeserialize_bidi()walks it back into native Python.
What using it looks like
from firefox_bidi_worker import launch_firefox, FirefoxBiDi
proc, ws_url, profile = launch_firefox(port=9227) # isolated, visible Firefox
ff = FirefoxBiDi(ws_url) # auto-creates the BiDi session
ff.navigate("https://example.com")
print(ff.browser_version) # "153.0"
print(ff.evaluate("document.title")) # "Example Domain"
ff.screenshot("shot.png")
ff.type_text("hello"); ff.click_xy(640, 400) # real, trusted input
Every launch uses -no-remote plus a dedicated profile, so it’s a fully isolated instance — it never attaches to, mutates, or kills the Firefox you already have open. There’s a unit test that asserts those isolation flags can’t be quietly removed, and the whole pipeline is self-proving: python firefox_bidi_worker.py --selftest runs launch → session → navigate → read title → screenshot and reports green, with a 19-test suite locking the protocol contracts.
(The repo also ships a small livestream-tooling bonus — SEO/overlay/clip helpers and an obs-websocket client I built alongside it — but the core is the BiDi worker above.)
If you maintain a browser worker and want a Firefox backend without adopting a framework, or you just want a readable reference for how WebDriver BiDi works over a raw socket, it’s here, MIT-licensed: github.com/Zek21/firefox-cdp. Issues welcome, especially if the launch handshake differs on your Firefox version.