Give your AI agent (or your script, or your other laptop) your real, logged-in browser.
One self-contained binary. Speaks MCP, plain HTTP proxy and WebDriver. Drives Chrome over the DevTools Protocol.
Agents and scripts keep hitting the same wall: the page they need is behind a login, an SSO dance, a captcha you already solved, or a cookie banner you already dismissed β in your browser. Every tool that bridges into that browser today asks you to install an extension.
Tether doesn't. It attaches to Chrome over the DevTools Protocol that Chrome already speaks, and turns each tab into something any client can use:
- π An HTTP/HTTPS proxy.
curl -x, Pythonrequests, another browser, anything. Each request is replayed inside a real tab: real cookies, real storage, real JavaScript. You get the rendered page back. - π€ A WebDriver endpoint. Point Selenium at it and most scripts just work β against your actual signed-in profile.
- π§ An MCP server for agents. Claude, Cursor or your own agent get tools to read, click, type, screenshot and fetch in your real browser (docs/MCP.md).
- π₯οΈ A live remote-browser UI. Open
/remote/from your phone or another laptop, watch any tab in real time, and take over with mouse, keyboard and clipboard β even the tab your agent is driving (docs). - π§© One core, four faces. Proxy, WebDriver, MCP and the remote UI all ship today on the same tab sessions (architecture).
- π Safe by default. Loopback-only unless you set a token. Built to sit on a private Tailscale tailnet, not the internet.
1. Install
go install github.com/adeelahmad/tether/cmd/tether@latestOr brew install --cask adeelahmad/tap/tether, or grab a binary from Releases.
2. Start β this opens a dedicated Chrome window. Log in to a site (say, GitHub) once; the profile persists.
tether serve --launch3. Prove it β in another terminal, fetch a logged-in page through the proxy:
curl -s http://127.0.0.1:8787/tether/v1/ca.pem -o tether-ca.pem
curl -s -x http://127.0.0.1:8787 --cacert tether-ca.pem https://github.com/settings/profile \
| grep -o '<title>[^<]*</title>'You get your profile settings page β the one that redirects to a login screen for everyone else. π
Prefer your everyday Chrome? Skip
--launch. On Chrome 144+, openchrome://inspect/#remote-debugging, allow remote debugging, and runtether serveβ it finds the browser automatically. On any Chrome, you can also start it with--remote-debugging-port=9222 --user-data-dir=<dir>(Chrome requires a non-default profile directory for the debugging port).
flowchart LR
A["curl / requests"] -- "HTTP proxy" --> T
B["Selenium"] -- "WebDriver" --> T
D["AI agent"] -- "MCP" --> T
T["tether serve<br/>sessions Β· policy Β· auth"] -- "DevTools Protocol" --> C
subgraph C["Your Chrome (logged in)"]
T1["tab: github.com"]
T2["tab: mail.google.com"]
T3["tab: WebDriver session"]
end
One session = one real tab. Tether picks the tab for each request by:
- an explicit
X-Tether-Tab-IDheader, else - the
tether_tabcookie Tether set on first contact, else - one default tab per site (registrable domain via the Public Suffix
List, so
shop.example.co.ukandwww.example.co.ukshare a tab).
On first contact Tether opens a fresh background tab and returns its ID as
both an X-Tether-Tab-ID header and a Set-Cookie. That cookie is never
forwarded to Chrome or the site. A WebDriver session ID is a tab ID, so the
proxy and WebDriver can drive the same tab.
GET/HEAD navigate the tab and return the rendered DOM (or the original
bytes with X-Tether-Mode: raw). POST and friends run as a same-origin
fetch() inside the tab, so the browser attaches its own cookies. HTTPS works
by terminating CONNECT with a local CA you trust once (tether ca prints
its path).
P="-x http://127.0.0.1:8787 --cacert tether-ca.pem"
curl $P https://news.ycombinator.com/ # rendered page, your session
curl $P -H 'X-Tether-Wait: networkidle' https://app.example.com/dashboard
curl $P -H 'X-Tether-Mode: raw' https://example.com/data.json
curl $P -X POST -H 'Content-Type: application/json' -d '{"q":1}' https://api.example.com/search
curl $P -i https://github.com/ | grep -i x-tether-tab-id # which tab served it| Request header | Meaning |
|---|---|
X-Tether-Tab-ID |
Use this tab (any open tab ID works; unknown β 410 tab_gone) |
X-Tether-Mode |
auto (default), rendered, raw, fetch |
X-Tether-Wait |
load (default), domcontentloaded, networkidle, none |
X-Tether-Timeout |
Per-request timeout, e.g. 30s |
Responses carry X-Tether-Tab-ID, X-Tether-Final-URL (after redirects)
and, on errors, a stable X-Tether-Error code (tab_gone, forbidden,
navigation_failed, timeout, β¦).
claude mcp add --transport http tether http://127.0.0.1:8787/mcpThen ask: "Open my GitHub notifications and summarise what needs me." The
agent gets browser_navigate, browser_snapshot, browser_click,
browser_type, browser_screenshot, browser_fetch and more, all in your
signed-in Chrome. Claude Desktop and other stdio clients use the bundled
bridge ("command": "tether", "args": ["mcp"]). Setup for every client,
the full tool list and the security notes: docs/MCP.md.
from selenium import webdriver
d = webdriver.Remote("http://127.0.0.1:8787", options=webdriver.ChromeOptions())
d.get("https://github.com/notifications")
print(d.title)
d.quit()Covered commands and behaviour: docs/WEBDRIVER.md.
Open http://127.0.0.1:8787/remote/ (or just the server address) in any
browser. Pick a tab, see it live, click, scroll, type, paste; answer its
alert/confirm dialogs; open, navigate and close tabs. Toggle watch only
to look over an agent's shoulder without disturbing it. With --token, the UI
asks for the token once and keeps a strict, UI-only cookie.
docs/REMOTE.md covers the controls, Tailscale setup and the
websocket protocol.
curl -s http://127.0.0.1:8787/tether/v1/tabs # list sessions
curl -s -X POST http://127.0.0.1:8787/tether/v1/tabs -d '{"url":"https://example.com"}'
curl -s -X POST http://127.0.0.1:8787/tether/v1/tabs/$TAB/eval \
-d '{"expression":"document.title"}' # Runtime.evaluate
curl -s "http://127.0.0.1:8787/tether/v1/tabs/$TAB/logs?level=warning" # console + browser logs
curl -s -X DELETE http://127.0.0.1:8787/tether/v1/tabs/$TAB # closeEvery tab Tether drives keeps its recent console.* output, uncaught
exceptions and browser log entries (failed requests, security warnings).
GET β¦/logs returns {"entries": [...], "next": N}; pass ?after=N to read
only what is new, and ?level=debug|info|warning|error to filter. The same
buffer backs WebDriver's driver.get_log("browser").
export TETHER_TOKEN="$(openssl rand -hex 32)"
tether serve --launch --addr "$(tailscale ip -4):8787"
# elsewhere on the tailnet:
curl -x "http://tether:$TETHER_TOKEN@my-laptop:8787" --cacert tether-ca.pem https://github.com/
# or watch and drive it from your phone: http://my-laptop:8787/remote/tether serve --launch --tailscale (or [tailscale] enabled = true in the
config file) picks the tailnet address for you, and --tailscale-publish serve
adds an HTTPS name via tailscale serve; see docs/CONFIG.md.
Tether refuses to listen beyond loopback without a token. Read SECURITY.md before exposing it anywhere.
Give every agent its own token with its own domain rules. Each token only sees the tabs it opened, and the rules are enforced inside the browser, so redirects, link clicks and page script can't wander off the allowlist either.
tether serve --policy-file examples/policy.json # tokens + rules, see docs/POLICIES.md
curl -s -H "Authorization: Bearer $TETHER_RESEARCH_TOKEN" http://127.0.0.1:8787/tether/v1/whoami
# lock a single tab tighter still
curl -s -H "Authorization: Bearer $TETHER_RESEARCH_TOKEN" -X POST http://127.0.0.1:8787/tether/v1/tabs \
-d '{"url":"https://en.wikipedia.org/","policy":{"allow":["en.wikipedia.org"]}}'Every flag has a TETHER_* environment variable, and almost everything can
also go in a TOML config file: ~/.config/tether/config.toml is read if it
exists, or pass --config. Precedence is flag > environment > file > default.
The file also holds extra tokens, headers Chrome sends, cookies to seed, the
Chrome profile to launch and MCP OAuth. Start from
tether.example.toml; the full reference is
docs/CONFIG.md.
| Flag | Default | |
|---|---|---|
--config |
~/.config/tether/config.toml if present |
TOML config file β see docs/CONFIG.md |
--addr |
127.0.0.1:8787 |
Listen address |
--token / --token-file |
β | Bearer token (β₯16 chars); required off-loopback |
--insecure-allow-public |
false |
Allow off-loopback with no token (don't) |
--chrome |
auto |
auto, a port, host:port, http://β¦, ws://β¦, or a DevToolsActivePort path |
--launch / --headless |
false |
Launch a dedicated Chrome (persistent profile under --data-dir) |
--chrome-bin, --chrome-args, --profile-dir |
auto | With --launch |
--chrome-profile |
last used | With --launch: profile inside --profile-dir, e.g. "Profile 1" |
--idle-timeout |
15m |
Close tabs Tether opened after inactivity (0 = never) |
--timeout |
60s |
Default per-request timeout |
--console-buffer |
1000 |
Console/log entries kept per tab (0 = no capture) |
--allow-domains |
all | Comma-separated navigation allowlist (applies to every face) |
--deny-domains |
none | Comma-separated denylist; deny beats allow |
--policy-file |
β | JSON or TOML file with per-token rules and tokens β see docs/POLICIES.md |
--data-dir |
user config dir | Local CA and launched profile |
--no-mitm |
false |
Refuse HTTPS CONNECT instead of intercepting |
--mcp |
true |
Serve the MCP face at /mcp |
--mcp-oauth |
false |
Let MCP clients sign in with OAuth β see docs/CONFIG.md |
--public-url |
from request | Externally visible origin (OAuth issuer) |
--tailscale |
false |
Listen on this machine's tailnet address (keeps the --addr port) |
--tailscale-publish |
β | serve or funnel: also publish over HTTPS via the tailscale CLI |
--no-remote |
false |
Don't serve the remote-browser UI at /remote/ |
--remote-quality |
70 |
JPEG quality of the remote UI video (1β100) |
--log-level, --log-format |
info, text |
json for machines; secrets are always redacted |
Docker (headless Chromium inside the image):
docker run --rm -p 8787:8787 -e TETHER_TOKEN=$(openssl rand -hex 32) -v tether:/data ghcr.io/adeelahmad/tetherAn honest look. Tether's trade-off is that HTTPS proxying needs you to trust a local CA, and it only drives Chromium-family browsers.
| Tether | Extension-based bridges | Cloud browsers | Fresh headless automation | |
|---|---|---|---|---|
| Uses your real logged-in profile | β | β | β (re-login, stored creds) | β |
| Needs a browser extension | No | Yes | No | No |
| Plain HTTP proxy (curl, any language) | β | β | Rarely | β |
| W3C WebDriver / Selenium | β incl. frames, alerts, windows, uploads, PDF | β | Often | β |
| MCP for agents | β | Often | Often | Via wrappers |
| Runs entirely on your machine | β | β | β | β |
| Remote access | Tailscale + token | Usually local only | β | DIY |
| Watch & take over a tab live | β built-in web UI | β | β | β |
| Single static binary | β | β | n/a | β (driver + runtime) |
| Browsers | Chrome / Chromium / Edge | Varies | Varies | Many |
- Tab-session core, HTTP/HTTPS proxy, WebDriver common set, control API
- WebDriver Actions: hover, drag and drop, key chords, wheel, multi-touch
- Safe-by-default exposure, token auth, domain allowlist seam
- MCP server face: 16 browser tools over the same core, plus a stdio bridge
- Remote-browser UI: live screencast, mouse/keyboard/clipboard, dialogs, tab management
- Per-token and per-session domain policies, enforced in the browser (docs/POLICIES.md)
- Per-tab console and browser log capture (control API + WebDriver
get_log)
Issues and PRs welcome β start with CONTRIBUTING.md and
docs/ARCHITECTURE.md. make check runs what CI runs.
GPL-3.0-or-later Β© Adeel Ahmad. The proxy front door builds on snail007/goproxy (GPLv3).