Skip to main content
Glama
TrueNix
by TrueNix
README.md
# kitesurf-bridge

Drive **[Cloudflare Kitesurf](https://developers.cloudflare.com/browser-run/kitesurf/)** — the agent-first browser that runs in V8 isolates on Cloudflare Workers — from anywhere. Zero dependencies, no local Chrome.

Ships four ways to use one engine:

| Surface | Install | Use it for |
| --- | --- | --- |
| **MCP server** | `npx -y github:TrueNix/kitesurf-bridge mcp` | Claude Code, Cursor, Codex, any MCP client |
| **CLI** | `npx -y github:TrueNix/kitesurf-bridge markdown <url>` | shells, scripts, CI |
| **Library** | `import { withSession } from 'kitesurf-bridge'` | your own Node code |
| **DSH / Cordis plugin** | composition row | native tools in a DSH harness |

> Install commands below use the GitHub spec, which works today with no registry
> account. Once published to npm as `@truenix/kitesurf-bridge`, every
> `github:TrueNix/kitesurf-bridge` shortens to `@truenix/kitesurf-bridge`.

```bash
npx -y github:TrueNix/kitesurf-bridge markdown https://news.ycombinator.com
```

That renders a real page in a real browser engine, on Cloudflare's network, with **no browser installed locally** and **no API token**.

---

## Why this exists

Kitesurf is not open source and cannot run on your machine. Cloudflare [says](https://blog.cloudflare.com/kitesurf/) they intend to open source it "once we're ready", and even then the stated goal is for customers to *"deploy their own version of Kitesurf on their own accounts"* — still on Workers.

There is also no local Kitesurf in the dev loop: `wrangler dev` launches **your local Chrome**, not Kitesurf. Kitesurf only exists behind `browser=kitesurf` on remote endpoints.

So the practical question is not "can I run it locally" but "can I *drive* it from local code". This package is that bridge.

## Install

### As an MCP server

<details open>
<summary><b>Claude Code</b></summary>

```bash
claude mcp add kitesurf -- npx -y github:TrueNix/kitesurf-bridge mcp
```
</details>

<details>
<summary><b>Cursor / Windsurf / generic <code>mcpServers</code> JSON</b></summary>

```json
{
  "mcpServers": {
    "kitesurf": {
      "command": "npx",
      "args": ["-y", "github:TrueNix/kitesurf-bridge", "mcp"]
    }
  }
}
```
</details>

<details>
<summary><b>With a Cloudflare account (production)</b></summary>

```json
{
  "mcpServers": {
    "kitesurf": {
      "command": "npx",
      "args": ["-y", "github:TrueNix/kitesurf-bridge", "mcp"],
      "env": {
        "CLOUDFLARE_ACCOUNT_ID": "your-account-id",
        "CLOUDFLARE_API_TOKEN": "your-browser-run-token"
      }
    }
  }
}
```
</details>

Tools exposed: `kitesurf_markdown`, `kitesurf_text`, `kitesurf_html`, `kitesurf_links`, `kitesurf_screenshot`, `kitesurf_evaluate`, `kitesurf_accessibility_tree`, `kitesurf_probe`.

### As a DSH / Cordis plugin

```yaml
# in an agent preset composition
- '@truenix/kitesurf-bridge/cordis':
    cli: npx -y github:TrueNix/kitesurf-bridge
    timeoutMs: 120000
```

The plugin registers the same tools on the host. It deliberately shells out to the CLI: a dynamic Cordis host half has no `WebSocket`, `fetch` or `node:*` access, so CDP cannot be opened inside the sandbox. See [`cordis/plugin.mjs`](cordis/plugin.mjs).

### As a library

```bash
npm install github:TrueNix/kitesurf-bridge
```

```js
import { withSession } from '@truenix/kitesurf-bridge';

const md = await withSession({}, async (session) => {
  await session.navigate('https://example.com');
  return session.markdown();
});
```

## CLI

```
kitesurf-bridge <command> [options]

  markdown <url>     Extract the page as Markdown (main content by default)
  text <url>         Visible text only
  html <url>         Full serialized DOM after JS runs
  links <url>        Every anchor as JSON
  screenshot <url>   PNG/JPEG   (-o file, --full)
  pdf <url>          PDF        (-o file)
  a11y <url>         Filtered accessibility tree
  eval <url> <expr>  Evaluate JS in the page
  probe              Endpoint + engine capability report
  mcp                Run as an MCP server on stdio
```

Useful options: `--main`, `--raw`, `--full`, `--width`, `--height`, `--json`, `--endpoint`, `--account`, `--token`, `--timeout`.

## Endpoints

| | Playground (default) | Account |
| --- | --- | --- |
| URL | `wss://kitesurf.cloudflare.app/devtools/page/kitesurf` | `wss://api.cloudflare.com/.../devtools/browser?browser=kitesurf` |
| Auth | none | `Authorization: Bearer <token>` |
| Target | page | browser (a page is created + attached automatically) |
| Suitable for | evaluation | production |

Set `CLOUDFLARE_ACCOUNT_ID` + `CLOUDFLARE_API_TOKEN` (or `CF_*`) to switch. Supplying an account id **without** a token is a hard error rather than a silent downgrade to the shared playground.

> [!WARNING]
> The playground is a free, shared, unauthenticated resource with no SLA. Fine for evaluation and local agent work — do not build production on it.

## Things worth knowing about Kitesurf

These are verified against the live service, not copied from docs. `kitesurf-bridge probe` reproduces them.

**Kitesurf does not run V8 for page script — it runs [Boa](https://boajs.dev/), a Rust JS engine.** Boa enforces a much lower recursion limit and throws `RuntimeLimit: exceeded maximum number of recursive calls`. A natural recursive DOM walk dies on any large page (Wikipedia, docs sites). This package's Markdown converter therefore walks the DOM with an **explicit stack**, keeping JS call depth at O(1). If you use `kitesurf_evaluate`, prefer iterative expressions.

**Navigation failures arrive as Cloudflare edge status codes, not CDP errors.** `Page.navigate` returns a normal `frameId`/`loaderId` even for a nonexistent host, and no `Network.loadingFailed` fires. A missing domain shows up as **HTTP 530**, a broken origin as **520**, leaving a ~16-character placeholder document. Trusting `Page.navigate` hands an agent a blank page and calls it success — so this package classifies outcomes from the Network domain and throws when a `>=400` status comes with an empty document, while still returning real error pages (with `status`) that have readable content.

**Capability flags** (verified):

| | |
| --- | --- |
| ✅ canvas2d, WebAssembly, shadow DOM, localStorage, cookies, `fetch`/XHR, IntersectionObserver, MutationObserver | |
| ❌ **WebGL**, **ServiceWorker**, video/audio playback, real TLS-fingerprint bot-challenge handshakes, long-lived authenticated sessions | |

For those, use Browser Run's default Chromium browser instead.

**Performance trade** (Cloudflare's own figures): Kitesurf uses **3–7× less CPU and memory** than warm Chromium, but is **1.7–1.8× slower in wall time**. That win is on *Cloudflare's* bill for bursty cloud agent workloads — it saves nothing on your own hardware. If you just want local browser automation and already have Chrome, local Playwright is faster and does WebGL and video.

## Zero dependencies

`package.json` has an empty `dependencies` block, including for the WebSocket transport.

Node's global `WebSocket` (WHATWG) cannot send request headers, and the account endpoint needs `Authorization: Bearer …`. `undici` is not importable as a standalone module. So [`src/ws.mjs`](src/ws.mjs) implements the RFC 6455 client directly over `node:http(s)` — handshake, masking, continuation fragments, 64-bit lengths, ping/pong, close — which is everything CDP needs, with header support.

## Tests

```bash
npm test                        # live tests against the playground
KITESURF_SKIP_NETWORK=1 npm test   # offline only
```

The suite hits the real service on purpose: the interesting failures (Boa's recursion limit, pipe truncation, edge status codes) only appear against the real thing.

## Requirements

Node ≥ 18. No browser, no API token, no build step.

## License

MIT