Deck Help Center

Connect Playwright, Puppeteer or Selenium

Ask the API to open a profile, then attach your automation library to the DevTools endpoint it hands back.

Updated Β· 5 min read

On this page

The pattern is the same in every language: ask LoginDeck to open the profile, then attach to the endpoint it gives you. Never launch the browser yourself β€” a browser your library started is a browser with none of the profile's fingerprint, proxy or cookies, and every detector knows what it looks like.

Part of Pro, Team and Custom.

First turn the API on and copy your token: Get started with the local API.

πŸ”‘ What browser/start hands back#

curl -s http://127.0.0.1:19222/api/v1/browser/start \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "My profile"}'

The data object contains the endpoint, said three ways because the libraries want it different ways:

FieldUse it for
wsPuppeteer's connect, Playwright's connectOverCDP
httphttp://127.0.0.1:<port> β€” handy for curl and for checking the browser is up
debug_portSelenium's debugger_address
engine.versionWhich Chrome build you are attached to, for example Chrome/155.0.0.0

Also in there: proxy (without its password), timezone, exit (the IP, country, city and ISP the session actually comes out of) and headless.

🎭 Playwright (JavaScript)#

import { chromium } from 'playwright';

const AO = { base: 'http://127.0.0.1:19222', token: 'YOUR_TOKEN' };

async function ao(path, body) {
  const res = await fetch(AO.base + path, {
    method: body ? 'POST' : 'GET',
    headers: {
      Authorization: 'Bearer ' + AO.token,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const { ok, data, error } = await res.json();
  if (!ok) throw new Error(error);
  return data;
}

const session = await ao('/api/v1/browser/start', { name: 'My profile' });

const browser = await chromium.connectOverCDP(session.ws);

// The profile's OWN context. newContext() would give you a blank one
// with none of the profile's cookies.
const ctx = browser.contexts()[0];
const page = ctx.pages()[0] || await ctx.newPage();

await page.goto('https://iphey.com');
console.log(await page.title());

// Leave the browser to LoginDeck, and close it through the API.
await ao('/api/v1/browser/stop', { name: 'My profile' });

newContext() throws the profile away

A new context has no cookies, no local storage and no logged-in sessions. Always take browser.contexts()[0]. The same goes for newPage() on the browser rather than on that context.

πŸ• Puppeteer (JavaScript)#

import puppeteer from 'puppeteer-core';

// ao() as above
const session = await ao('/api/v1/browser/start', { name: 'My profile' });

const browser = await puppeteer.connect({
  browserWSEndpoint: session.ws,
  // null keeps the profile's own window size
  defaultViewport: null,
});

const [page] = await browser.pages();
await page.goto('https://iphey.com');
console.log(await page.title());

// disconnect leaves the browser open β€” close it through the API
browser.disconnect();
await ao('/api/v1/browser/stop', { name: 'My profile' });

Use puppeteer-core: you are attaching to LoginDeck's browser, so there is no reason to download Puppeteer's own Chromium.

🐍 Playwright (Python)#

import requests
from playwright.sync_api import sync_playwright

AO = "http://127.0.0.1:19222"
H = {"Authorization": "Bearer YOUR_TOKEN"}

s = requests.post(
    f"{AO}/api/v1/browser/start",
    headers=H, json={"name": "My profile"},
).json()["data"]

print(s["ws"], s["exit"])

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(s["ws"])
    ctx = browser.contexts[0]
    page = ctx.pages[0] if ctx.pages else ctx.new_page()
    page.goto("https://iphey.com")
    print(page.title())

requests.post(f"{AO}/api/v1/browser/stop", headers=H, json={"name": "My profile"})

πŸ§ͺ Selenium (Python)#

Selenium attaches to the same debugging port through debugger_address. It needs a chromedriver that matches the engine's major version, which engine.version in the reply tells you (Chrome/155.0.0.0 β†’ chromedriver 155).

import requests
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

AO = "http://127.0.0.1:19222"
H = {"Authorization": "Bearer YOUR_TOKEN"}

s = requests.post(
    f"{AO}/api/v1/browser/start",
    headers=H, json={"name": "My profile"},
).json()["data"]

print("engine:", s["engine"]["version"])   # which chromedriver you need

opts = Options()
opts.debugger_address = f"127.0.0.1:{s['debug_port']}"

driver = webdriver.Chrome(options=opts)    # attaches, does not launch
driver.get("https://iphey.com")
print(driver.title)

# Do NOT call driver.quit() β€” it would kill the browser out from under
# LoginDeck. Detach, then stop the session through the API.
requests.post(f"{AO}/api/v1/browser/stop", headers=H, json={"name": "My profile"})

In Java or C#, the same idea: ChromeOptions.setExperimentalOption("debuggerAddress", "127.0.0.1:" + debugPort).

πŸ“‹ Rules that save you an afternoon#

  • Do not launch, attach. No chromium.launch(), no puppeteer.launch(), no plain webdriver.Chrome() without debugger_address.
  • Stop the session through the API, not by closing the browser from your library. That keeps the app's state, the profile's lease and the cookie save correct.
  • One session per profile. A second browser/start on a profile that is already open returns the session that is already running; a profile opened by hand from the app cannot be taken over (this profile is already running without an automation port β€” stop it first).
  • Windowed unless you have a reason. {"headless": true} is allowed and the reply warns you: no real display, a software GPU path, and several APIs behave differently.
  • Chromium only. Firefox profiles cannot be automated yet.
  • Give the app a moment. browser/start returns once the browser has a debugging port, so by the time you have the ws URL you can connect straight away β€” no sleep needed.

🧠 Or let a Flow do the clicking#

If what you are writing is "open page, click, type, read", that already exists as a Flow, and a Flow survives the site being redesigned better than a selector-based script does. Your script can start one and wait for the outcome:

curl -s http://127.0.0.1:19222/api/v1/flows/run \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"flow": "My flow", "name": "My profile", "wait": true}'

See What Flows are and the Local API reference.

ℹ️ Good to know#

  • The endpoint is on 127.0.0.1 and changes every launch β€” read it from the reply each time rather than hard-coding a port.
  • GET /api/v1/browser/list gives you every running session and its endpoint, so a script that lost its connection can reattach.
  • LoginDeck must stay open. It is the app that owns the browsers.
  • If engine.patched comes back false, the session is on stock Chrome and the canvas, WebGL, audio and font masking is inert. See What no antidetect browser can change.