A minimal Python library for driving a real Firefox over WebDriver BiDi, with a flat, CDP-style API —
navigate/evaluate/screenshot/type/clickover a single websocket, in ~700 lines. One dependency (websocket-client), the rest is the standard library. No Selenium, no geckodriver, no Playwright server.Read this first (what it is / isn't): this is a Python library you
import— a CDP-style API, named for the ergonomics you already know. It is 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. Under the hood it speaks Firefox's WebDriver BiDi; "CDP-style" describes the shape of the API, not the wire protocol.
Live-verified on Firefox Developer Edition 153 (Windows 11).
python firefox_bidi_worker.py --selftest --json
# launch → BiDi session → navigate → read title → screenshot, all greenFirefox shipped an experimental Chrome DevTools Protocol implementation in 2021 (FF 86). Mozilla then chose to standardize on the W3C's WebDriver BiDi and retire the CDP it didn't control:
- Firefox 129 (May 2024): CDP deprecated — off by default, 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.
BiDi isn't "CDP with new names." It's a different, standardized, bidirectional protocol — a
superset of what Firefox's CDP did. The one thing that carried over is the transport
shape: JSON-RPC over one websocket ({id, method, params} → {id, type, result}). That's
what lets a CDP-style Tab map onto it with almost no structural change.
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.
This is for the other case: a small automation script or agent that already thinks in
terms of a minimal Tab and shouldn't pull in a whole framework just to get a Firefox
backend. 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 adapter handles all of these; they're here 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 before anybrowsingContext.*/script.*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". script.evaluatereturns a BiDi remote value, not plain JSON. A smalldeserialize_bidi()walks it back into native Python.
git clone https://github.com/Zek21/firefox-cdp
cd firefox-cdp
pip install -r requirements.txt # websocket-client (+ Pillow/pytest for the bonus kit & tests)External: Firefox (Developer Edition preferred; set FIREFOX_BIN if it's non-standard).
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")
# real, trusted input (React-safe) — not synthetic .click()
ff.type_text("hello world")
ff.click_xy(640, 400)
ff.press_keys("") # EnterFirefoxBiDi gives you navigate, current_url, evaluate (clean native Python via
deserialize_bidi), screenshot, activate, type_text, press_keys, click_xy,
new_tab, and the raw rpc(method, params) escape hatch. FirefoxSession adds the
Chrome-CDP tab model: one window, many named tabs, open_tab / switch.
python firefox_bidi_worker.py --navigate https://x.com --screenshot out.png
python firefox_bidi_worker.py --tabs "https://a.com,https://b.com" --cycles 2
python firefox_bidi_worker.py --trace-chrome --duration 120 # mirror a live Chrome tab into FirefoxEvery launch uses -no-remote + a dedicated -profile, so it's a fully isolated instance
— it never attaches to, mutates, or kills the Firefox you already have open. Cleanup kills only
processes whose command line contains that unique profile path. A unit test asserts those flags
can't be quietly removed. Optional: launch_firefox(seed_profile=...) copies only auth
cookies read-only from an existing profile so the driven instance is logged in, without touching
your running Firefox.
python firefox_bidi_worker.py --selftest --json # 5/5: launch→session→nav→title→screenshot (FF 153)
pytest -q # 19 passed — pure-logic + protocol-contract guardsBuilt alongside the worker (and kept here for convenience, not the main event):
stream_kit.py (YouTube/Twitch SEO, a center-safe lower-third overlay, auto-highlight clips,
A/B thumbnails), obs_stream_control.py (an obs-websocket v5 client — scenes/sources/stream,
with a self-test against an in-process mock server), and youtube_edit_firefox.py (a worked
example of driving a real logged-in site through the BiDi worker). Each has its own
--selftest. See docs/PROTOCOL.md for the full BiDi-vs-CDP notes.
firefox_bidi_worker.py # the core: BiDi Tab primitive + launcher + Chrome mirror
tests/ # fast, deterministic protocol-contract guards (no browser/network)
docs/PROTOCOL.md # the full WebDriver-BiDi-vs-CDP protocol notes
stream_kit.py # bonus: stream SEO / overlays / clips / thumbnails
obs_stream_control.py # bonus: obs-websocket v5 client
youtube_edit_firefox.py # bonus: worked "drive a logged-in site" example
MIT — see LICENSE. Built by Exzil Calanza. Issues welcome, especially if the launch handshake behaves differently on your Firefox version.