---
title: "Migrate from nodriver or zendriver"
description: "Attach nodriver or zendriver to a Scalebrowser profile by host and port, keep your code, and replace the script-dispatched clicks and key events a page can see."
canonical: "https://scalebrowser.net/docs/migrate/nodriver"
---

> ## Documentation Index
> Fetch the complete documentation index at: https://scalebrowser.net/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate from nodriver or zendriver

> Attach nodriver or zendriver to a Scalebrowser profile by host and port, keep your code, and replace the script-dispatched clicks and key events a page can see.

nodriver already does what the SDKs do: it speaks CDP directly instead of through a Playwright or Selenium control plane. So the driving model stays the same, and what changes is the browser underneath. zendriver, the fork that releases more often today, attaches the same way.

## Start a profile and attach

nodriver attaches by **host and port**, not by WebSocket address: it asks `http://<host>:<port>/json/version` for the address itself, and with both set it starts no browser of its own. Take the port from the `debug_port` field of the start response. It is new at every start.

```python
import asyncio
import os

import nodriver as uc
from scalebrowser import AsyncScalebrowserClient

PROFILE_ID = "prf_7d2a…"   # an existing profile

async def main():
    sb = AsyncScalebrowserClient(base_url="http://127.0.0.1:8787", token=os.environ["SCALEBROWSER_TOKEN"])
    started = await sb.start_profile(PROFILE_ID)
    try:
        browser = await uc.start(host="127.0.0.1", port=started.debug_port)
        tab = await browser.get("https://example.com")
        await tab.save_screenshot("example.jpg")
    finally:
        await sb.stop_profile(PROFILE_ID)
        await sb.aclose()

uc.loop().run_until_complete(main())
```

For zendriver, `import zendriver as zd` and `await zd.start(host="127.0.0.1", port=started.debug_port)`; the rest reads the same.

Three things change against a browser nodriver launched itself:

- **Leave out the launch options.** `user_data_dir`, `browser_args`, `headless` and `lang` shape a launch, and nothing is launched. The profile already keeps its cookies and storage, its language comes from its identity, and `headless` goes on the start call.
- **Put the proxy on the profile.** `create_context(proxy_server=…)` opens a separate context beside the profile's own, with its own cookie jar. Store the proxy once and assign it instead; see [Proxies & exits](/docs/proxies).
- **Stop the profile through the daemon.** In zendriver, `browser.stop()` sends `Browser.close` and shuts the profile's browser down from under the daemon (read from its source). End with `stop_profile` as above.

## What the page sees from nodriver's input

nodriver injects no scripts to disguise itself, and its input gives it away. Measured on our own bench, with six automation tools filling the same form:

- **Clicks are dispatched from script.** They arrive with `isTrusted: false`, at coordinate 0,0, with no pointer events before them.
- **Typing has no key presses.** The page receives `keypress` events and not a single `keydown`.

Its `click()` also flashes the element first, adding a style rule and a red element to the page for a quarter of a second (read from nodriver's source). You can check what your own setup leaves behind on [/ai-agent-check](https://scalebrowser.net/ai-agent-check), without an account.

## Keep nodriver for finding, send the input through the daemon

You do not have to give up nodriver's selectors. Find the element with nodriver, read its box, and let the daemon's [human input layer](/docs/behaviour) produce the click and the typing. The input call goes to the profile's first page, which is the tab `browser.get` drives when you do not ask for a new one.

```python
import json

BOX = """(() => {
  const r = document.querySelector('#email').getBoundingClientRect();
  return JSON.stringify({ x: r.x + r.width / 2, y: r.y + r.height / 2, width: r.width });
})()"""

# inside main(), after tab = await browser.get(...)
box = json.loads(await tab.evaluate(BOX))
await sb.send_input(PROFILE_ID, {"action": "click", "x": box["x"], "y": box["y"],
                                 "width": box["width"], "humanize": True})
await sb.send_input(PROFILE_ID, {"action": "type", "text": "name@example.com", "humanize": True})
```

`width` tells the input layer how wide the target is, so the pointer lands inside the element rather than on its exact centre every time. The box comes back as JSON text because nodriver hands an object back in its own serialised form rather than as a dictionary. The four gestures and their fields are on [REST API](/docs/rest).

To leave nodriver behind entirely, the [SDKs](/docs/sdks) carry their own CDP client with the same calls built in: `humanize_click`, `humanize_type`, `humanize_scroll`.

## Next

- [Direct CDP](/docs/cdp): the endpoint and the `debug_port` field.
- [Human input](/docs/behaviour): how a click and a typed word are produced.
- [Migrate overview](/docs/migrate): the two steps, and what maps to what.
