Skip to content

Frameworks and the Playground

Notes for connecting particular frameworks to a hosted browser, and the Playground in the dashboard for trying a script next to the live view.

Frameworks

Anything that attaches to Chrome over CDP works with the connectUrl. It is single-use, so a framework that reconnects on its own needs a new session for each connection. The browser starts when you connect, usually within a few seconds (see the timeout note above); there is no status to poll first.

javascript
// Patchright (optional; a Playwright fork): npm i patchright
import { chromium } from "patchright";
const browser = await chromium.connectOverCDP(connectUrl, { timeout: 120_000 });
const page = browser.contexts()[0].pages()[0];
python
# Browser Use
from browser_use import Agent, Browser
agent = Agent(task="Find the cheapest flight to Lisbon next Friday", llm=llm, browser=Browser(cdp_url=connect_url))
await agent.run()

# Crawl4AI
from crawl4ai import AsyncWebCrawler, BrowserConfig
config = BrowserConfig(browser_mode="custom", cdp_url=connect_url, use_managed_browser=True)
async with AsyncWebCrawler(config=config) as crawler:
    result = await crawler.arun("https://example.com")
javascript
// Stagehand v4
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({ browser: await localBrowser.connect({ cdpUrl: connectUrl }) });

setContent and exposeFunction

The engine never sends console events to the client, because forwarding them is what pages that look for an automation client measure. Two driver calls depend on them:

  • Playwright's page.setContent() waits for a console event that never comes, so it times out (Puppeteer's works). Open the HTML as a data: URL instead, or write it into the page yourself, as below.
  • exposeFunction() and exposeBinding(), in Playwright and Puppeteer, work in the page that is open when you call them, and are gone once that page navigates. Call them after the last navigation, or pass results back from evaluate instead.
javascript
// Instead of await page.setContent(html):

// Short HTML: a data: URL (the page gets no origin of its own, so no cookies or storage)
await page.goto("data:text/html," + encodeURIComponent(html));

// Any HTML: write it into a page you have navigated to; it keeps that page's origin
await page.evaluate((html) => {
  document.open();
  document.write(html);
  document.close();
}, html);
python
# Instead of page.set_content(html):
from urllib.parse import quote

page.goto("data:text/html," + quote(html))
# or
page.evaluate("html => { document.open(); document.write(html); document.close(); }", html)

A helper to start from

Create with the retries above, connect, and always stop, in one function:

typescript
// clearcote-hosted.ts
import { chromium, type Browser } from "playwright"; // or "patchright"

const API = "https://www.clearcotelabs.com/api/v1/browsers";
const AUTH = { authorization: "Bearer " + process.env.CLEARCOTE_API_KEY };
const RETRY_CODES = new Set(["NO_CAPACITY", "NO_WORKER"]);

export async function createSession(options: Record<string, unknown> = {}, attempts = 5) {
  for (let i = 0; ; i++) {
    const res = await fetch(API, {
      method: "POST",
      headers: { ...AUTH, "content-type": "application/json" },
      body: JSON.stringify(options),
    });
    const body = await res.json().catch(() => ({}));
    if (res.ok) return body as { id: string; connectUrl: string; worker: string };
    const retry = RETRY_CODES.has(body.code) || (res.status === 429 && !body.code) || [500, 502, 504].includes(res.status);
    if (!retry || i + 1 >= attempts) throw new Error([res.status, body.code, body.error].filter(Boolean).join(" "));
    await new Promise((r) => setTimeout(r, Math.min(15_000, 1000 * 2 ** i) * (0.5 + Math.random())));
  }
}

export async function withBrowser<T>(options: Record<string, unknown>, work: (browser: Browser) => Promise<T>) {
  const session = await createSession(options);
  try {
    const browser = await chromium.connectOverCDP(session.connectUrl, { timeout: 120_000 });
    try {
      return await work(browser);
    } finally {
      await browser.close().catch(() => {});
    }
  } finally {
    // Ends the session if closing the browser did not (a no-op otherwise).
    await fetch(API + "/" + session.id, { method: "DELETE", headers: AUTH }).catch(() => {});
  }
}

// await withBrowser({ profile: { name: "shop", persist: true }, country: "de" }, async (browser) => {
//   const page = browser.contexts()[0].pages()[0];
//   await page.goto("https://example.com");
// });

Uploading files

A page that asks for a file — an <input type="file">, a “browse” button, a drag-and-drop area — takes one the same way in a hosted browser as in a local one. The file is read on your machine and its bytes travel over the connection, so the browser never sees a path on your disk and you can upload a file that only exists where your script runs. The limit is 50 MB per file.

Playwright is the simplest. setInputFiles puts a file straight into the input, with no click, and works even when the input is hidden — many sites hide the real input behind a styled button, so target the input itself rather than clicking.

javascript
// Node.js + Playwright
await page.setInputFiles("input[type=file]", "C:/Users/me/invoice.pdf");

// or build the file in your script, with nothing on disk:
await page.setInputFiles("input[type=file]", {
  name: "invoice.pdf",
  mimeType: "application/pdf",
  buffer: Buffer.from("%PDF-1.4 …"),
});
python
# Python + Playwright
page.set_input_files("input[type=file]", "/home/me/invoice.pdf")

When a button opens the system file dialog and there is no input you can target, catch the chooser instead:

javascript
const [chooser] = await Promise.all([
  page.waitForEvent("filechooser"),
  page.click("text=Browse"),           // the page's own button
]);
await chooser.setFiles("C:/Users/me/invoice.pdf");

Puppeteer. elementHandle.uploadFile() and fileChooser.accept() are not available on hosted browsers: they name a path for the server to read, which could point anywhere on the worker, so the gateway refuses them. Build the file inside the page from bytes you send in, and hand it to the input:

javascript
// Puppeteer: construct the file in the page
const b64 = fs.readFileSync("invoice.pdf").toString("base64");
await page.evaluate(({ b64, name, type }) => {
  const data = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
  const dt = new DataTransfer();
  dt.items.add(new File([data], name, { type }));
  const input = document.querySelector("input[type=file]");
  input.files = dt.files;
  input.dispatchEvent(new Event("change", { bubbles: true }));
}, { b64, name: "invoice.pdf", type: "application/pdf" });

The same DataTransfer drives a drag-and-drop zone: dispatch dragenter, dragover and drop on it carrying that object. A real CDP drag with a local path (Input.dispatchDragEvent with files) is refused for the same reason as uploadFile().

The Playground

The Playground in the dashboard runs a script against one of these browsers with the live view, console and screenshots next to it, and the “Use in your code” panel under it shows the same session options as the code above. Its page is a small helper spoken directly over CDP, not Playwright, so a Playground script is a sketch to translate, not a file to paste:

HelperDoes
page.goto(url, { timeout? })Navigate and wait for the load event.
page.click(sel) · page.type(sel, text) · page.press(key)Real mouse and keyboard input, scrolled into view first.
page.evaluate(fn, ...args)Run a function in the page and get its JSON result back.
page.waitForSelector(sel, { timeout? }) · page.waitForNavigation()Wait for an element, or for the next page load.
page.scroll(px) · page.screenshot({ fullPage? })Mouse-wheel scroll; a JPEG that lands in the Screenshots tab.
page.title() · page.url() · page.content()The document title, address and HTML.
log(...values) · sleep(ms)Print to the console (objects are pretty-printed); pause.
cdp(method, params)A raw CDP command to the browser (Target.*, Browser.*, Storage.*).
page.cdp(method, params)A raw CDP command to the page (Page.*, Runtime.*, DOM.*, Network.*).

Errors report the script line they came from. Share copies a link that carries the script and its session options in the URL, so nothing is stored on our side; whoever opens it runs it on their own balance.