Cases / #58 · 2026-10-17 · Medium
Do it by hand in live view, then let your code finish in the same browser
Launch a browser with `start_url` and no client, drive it through a control-mode live link — address bar, Tab, typing — then connect your code: it finds the person's page in `contexts()[0]`, reads what they typed and submits the form. The watched session outlived its 30 s idle timeout; an unwatched twin ended at 30 s.
Run on production on 2026-10-17: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Some steps are easier by hand: finding out why a page blocks your scraper, clicking through a consent wall, typing a one-time code, or just trying a browser and proxy before writing any code. With Launch interactive a person does those steps in a live view, and the script picks up exactly where they stopped — same browser, same cookies, same page — instead of replaying everything from scratch. This case does that end to end over the API, which is also how the dashboard's "Try it live" works.
What we used, and why
| What | Why |
|---|---|
start_url (launch option) | The node opens this page itself, so the session runs before any client connects. Headless Chromium needs live_view: true to be watchable. |
POST https://<wsUrl host>/admin/session/live with mode: "control" | A single-use live link. For headless sessions it is a screencast WebSocket: JPEG frames in, and navigate, key and text messages out. |
inactivity_timeout: "30s" | Short on purpose, to see what counts as activity: input from the person, and an open viewer. |
browser.contexts()[0].pages() | Where your code finds the page the person was on when it connects to wsUrl. |
GET /v1/me/sessions | The account API's history: how long each session ran and why it ended. |
proxy | Your own proxy; a few KB of traffic. |
How it works
- Launch two identical sessions with
start_url: https://httpbin.org/and a 30 sinactivity_timeout; open a control-mode live link for the first one only. - In the live view, go to the order form from the address bar (
navigate) and fill the first three fields from the keyboard: Tab, then type. In the programs this person is a scripted stand-in sending the same messages the dashboard's viewer sends. - Stop all input and just watch for 45 s.
- Try to connect to the unwatched twin, then connect to the watched session and list the pages in its default context.
- Read the three typed fields, pick a size and a topping, set a delivery time and submit — httpbin echoes the form back. Close, and read both sessions from the history.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected] ws
// env: CDPFLEET_API_KEY, PROXY_URL (your own proxy; a few KB of traffic)
import { chromium } from 'playwright';
import WebSocket from 'ws';
const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(engine, body) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (res.status !== 503 || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
await sleep((Number(res.headers.get('retry-after')) || 5) * 1000);
}
}
// 1. Launch with a start page and no client: the browser opens it by itself. A twin session
// with the same options gets no viewer at all, to compare with.
const body = {
proxy: PROXY, headless: true, live_view: true, record: false,
start_url: 'https://httpbin.org/', inactivity_timeout: '30s',
};
const session = await launch('chromium', body);
const twin = await launch('chromium', body);
// 2. A live link in control mode — what "Launch interactive" in the dashboard opens for you.
const res = await fetch(`https://${new URL(session.wsUrl).host}/admin/session/live`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ session_id: session.sessionId, mode: 'control' }),
});
const live = await res.json();
// The viewer: JPEG frames (binary) and JSON messages; "pages" says which URL is on screen.
const viewer = new WebSocket(live.live_url);
const seen = [];
let frames = 0;
viewer.on('message', (data, binary) => {
if (binary) { frames++; return; }
const m = JSON.parse(data);
if (m.type === 'pages') {
const url = m.pages.find((p) => p.targetId === m.current)?.url;
if (url && seen.at(-1) !== url) seen.push(url);
}
});
await new Promise((ok, fail) => { viewer.once('open', ok); viewer.once('error', fail); });
const send = (m) => viewer.send(JSON.stringify(m));
const waitForUrl = async (part) => { for (let i = 0; i < 60 && !seen.some((u) => u.includes(part)); i++) await sleep(500); return seen.some((u) => u.includes(part)); };
const key = (k) => { send({ type: 'key', action: 'keyDown', key: k, code: k }); send({ type: 'key', action: 'keyUp', key: k, code: k }); };
// 3. The person (here a scripted stand-in): goes to the form from the address bar, then fills
// the first three fields from the keyboard — Tab to a field, type.
const startShown = await waitForUrl('httpbin.org');
send({ type: 'navigate', url: 'https://httpbin.org/forms/post' });
const formShown = await waitForUrl('/forms/post');
await sleep(1500);
for (const text of ['Ada Lovelace', '+44 20 7946 0000', '[email protected]']) {
key('Tab');
await sleep(200);
send({ type: 'text', text });
await sleep(300);
}
// 4. Then only watches — no input for 45 s, past the 30 s inactivity_timeout.
await sleep(45000);
// The unwatched twin has had no input and no viewer for as long: it should be gone.
let twinAlive = true;
try {
await (await chromium.connect(twin.wsUrl, { headers: { 'x-api-key': KEY }, timeout: 15000 })).close();
} catch {
twinAlive = false;
}
// 5. Your code joins the same browser and finds the person's page in the default context.
const browser = await chromium.connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
const out = {
live_protocol: live.protocol, start_page_shown: startShown, form_shown: formShown, frames_while_by_hand: frames,
watched_session_alive_after_45s: browser.isConnected(), unwatched_twin_alive_after_45s: twinAlive,
};
try {
const pages = browser.contexts()[0].pages();
const page = pages.find((p) => p.url().includes('/forms/post'));
out.pages_found = pages.map((p) => p.url());
out.typed_by_hand = {
custname: await page.inputValue('input[name=custname]'),
custtel: await page.inputValue('input[name=custtel]'),
custemail: await page.inputValue('input[name=custemail]'),
};
// 6. The code finishes the job: the choices a script makes reliably, then submit.
await page.check('input[name=size][value=medium]');
await page.check('input[name=topping][value=cheese]');
await page.fill('input[name=delivery]', '19:30');
await Promise.all([page.waitForURL('**/post'), page.click('form button')]);
const echo = JSON.parse(await page.textContent('body'));
out.submitted = echo.form;
await sleep(1500);
out.viewer_saw = seen;
} finally {
viewer.close();
await browser.close();
}
// 7. The account API's session history: how long each session ran and why it ended.
async function history(sessionId) {
for (let i = 0; i < 30; i++) {
const res = await fetch(`https://cdpfleet.com/v1/me/sessions?limit=50&from=${new Date(Date.now() - 3600000).toISOString()}`, { headers: { 'x-api-key': KEY } });
const row = (await res.json()).sessions.find((s) => s.session_id === sessionId);
if (row?.ended_at) return { seconds: Math.round((Date.parse(row.ended_at) - Date.parse(row.started_at)) / 1000), end_reason: row.end_reason };
await sleep(2000);
}
return null;
}
out.watched_session = await history(session.sessionId);
out.unwatched_twin = await history(twin.sessionId);
console.log(JSON.stringify(out, null, 2));
# pip install playwright==1.60.0 aiohttp
# env: CDPFLEET_API_KEY, PROXY_URL (your own proxy; a few KB of traffic)
import asyncio
import json
import os
from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse
import aiohttp
from playwright.async_api import async_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
PROXY = os.environ["PROXY_URL"]
HEADERS = {"x-api-key": KEY}
# 503 means the fleet is momentarily busy: wait Retry-After and try again.
async def launch(http, engine, body):
for attempt in range(1, 6):
async with http.post(f"https://starter.cdpfleet.com/{engine}/session", json=body) as r:
if r.ok:
return await r.json()
if r.status != 503 or attempt == 5:
raise RuntimeError(f"launch {r.status} {await r.text()}")
await asyncio.sleep(int(r.headers.get("retry-after", 5)))
def parse_time(s):
return datetime.fromisoformat(s.replace("Z", "+00:00"))
# The account API's session history: how long each session ran and why it ended.
async def history(http, session_id):
since = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat().replace("+00:00", "Z")
for _ in range(30):
async with http.get("https://cdpfleet.com/v1/me/sessions", params={"limit": "50", "from": since}) as r:
rows = (await r.json())["sessions"]
row = next((s for s in rows if s["session_id"] == session_id), None)
if row and row.get("ended_at"):
return {"seconds": round((parse_time(row["ended_at"]) - parse_time(row["started_at"])).total_seconds()), "end_reason": row["end_reason"]}
await asyncio.sleep(2)
return None
async def main():
async with aiohttp.ClientSession(headers=HEADERS) as http, async_playwright() as p:
# 1. Launch with a start page and no client: the browser opens it by itself. A twin session
# with the same options gets no viewer at all, to compare with.
body = {"proxy": PROXY, "headless": True, "live_view": True, "record": False,
"start_url": "https://httpbin.org/", "inactivity_timeout": "30s"}
session = await launch(http, "chromium", body)
twin = await launch(http, "chromium", body)
# 2. A live link in control mode — what "Launch interactive" in the dashboard opens for you.
host = urlparse(session["wsUrl"]).netloc
async with http.post(f"https://{host}/admin/session/live", json={"session_id": session["sessionId"], "mode": "control"}) as r:
live = await r.json()
# The viewer: JPEG frames (binary) and JSON messages; "pages" says which URL is on screen.
viewer = await http.ws_connect(live["live_url"], headers={}, max_msg_size=0)
seen, frames = [], 0
async def read():
nonlocal frames
async for msg in viewer:
if msg.type == aiohttp.WSMsgType.BINARY:
frames += 1
elif msg.type == aiohttp.WSMsgType.TEXT:
m = json.loads(msg.data)
if m.get("type") == "pages":
url = next((pg["url"] for pg in m["pages"] if pg["targetId"] == m["current"]), None)
if url and (not seen or seen[-1] != url):
seen.append(url)
reader = asyncio.create_task(read())
async def send(m):
await viewer.send_str(json.dumps(m))
async def wait_for_url(part):
for _ in range(60):
if any(part in u for u in seen):
return True
await asyncio.sleep(0.5)
return any(part in u for u in seen)
async def key(k):
await send({"type": "key", "action": "keyDown", "key": k, "code": k})
await send({"type": "key", "action": "keyUp", "key": k, "code": k})
# 3. The person (here a scripted stand-in): goes to the form from the address bar, then fills
# the first three fields from the keyboard — Tab to a field, type.
start_shown = await wait_for_url("httpbin.org")
await send({"type": "navigate", "url": "https://httpbin.org/forms/post"})
form_shown = await wait_for_url("/forms/post")
await asyncio.sleep(1.5)
for text in ["Ada Lovelace", "+44 20 7946 0000", "[email protected]"]:
await key("Tab")
await asyncio.sleep(0.2)
await send({"type": "text", "text": text})
await asyncio.sleep(0.3)
# 4. Then only watches — no input for 45 s, past the 30 s inactivity_timeout.
await asyncio.sleep(45)
# The unwatched twin has had no input and no viewer for as long: it should be gone.
twin_alive = True
try:
await (await p.chromium.connect(twin["wsUrl"], headers=HEADERS, timeout=15000)).close()
except Exception:
twin_alive = False
# 5. Your code joins the same browser and finds the person's page in the default context.
browser = await p.chromium.connect(session["wsUrl"], headers=HEADERS)
out = {
"live_protocol": live["protocol"], "start_page_shown": start_shown, "form_shown": form_shown, "frames_while_by_hand": frames,
"watched_session_alive_after_45s": browser.is_connected(), "unwatched_twin_alive_after_45s": twin_alive,
}
try:
pages = browser.contexts[0].pages
page = next(pg for pg in pages if "/forms/post" in pg.url)
out["pages_found"] = [pg.url for pg in pages]
out["typed_by_hand"] = {name: await page.input_value(f"input[name={name}]") for name in ["custname", "custtel", "custemail"]}
# 6. The code finishes the job: the choices a script makes reliably, then submit.
await page.check("input[name=size][value=medium]")
await page.check("input[name=topping][value=cheese]")
await page.fill("input[name=delivery]", "19:30")
async with page.expect_navigation(url="**/post"):
await page.click("form button")
out["submitted"] = json.loads(await page.text_content("body"))["form"]
await asyncio.sleep(1.5)
out["viewer_saw"] = seen
finally:
await viewer.close()
reader.cancel()
await browser.close()
# 7. The account API's session history.
out["watched_session"] = await history(http, session["sessionId"])
out["unwatched_twin"] = await history(http, twin["sessionId"])
print(json.dumps(out, indent=2))
asyncio.run(main())
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY, PROXY_URL (your own proxy; a few KB of traffic)
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String PROXY = System.getenv("PROXY_URL");
static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
static final HttpClient HTTP = HttpClient.newHttpClient();
static JsonElement post(String url, JsonObject body) throws Exception {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create(url))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException(url + " " + res.statusCode() + " " + res.body());
return JsonParser.parseString(res.body());
}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
static JsonObject launch(String engine, JsonObject body) throws Exception {
for (int attempt = 1; ; attempt++) {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject();
if (res.statusCode() != 503 || attempt == 5) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
}
}
// The account API's session history: how long each session ran and why it ended.
static JsonElement history(String sessionId) throws Exception {
String since = URLEncoder.encode(Instant.now().minusSeconds(3600).toString(), StandardCharsets.UTF_8);
for (int i = 0; i < 30; i++) {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://cdpfleet.com/v1/me/sessions?limit=50&from=" + since))
.header("x-api-key", KEY).build(), HttpResponse.BodyHandlers.ofString());
for (JsonElement e : JsonParser.parseString(res.body()).getAsJsonObject().getAsJsonArray("sessions")) {
JsonObject row = e.getAsJsonObject();
if (row.get("session_id").getAsString().equals(sessionId) && row.has("ended_at") && !row.get("ended_at").isJsonNull()) {
JsonObject out = new JsonObject();
long ms = Instant.parse(row.get("ended_at").getAsString()).toEpochMilli() - Instant.parse(row.get("started_at").getAsString()).toEpochMilli();
out.addProperty("seconds", Math.round(ms / 1000.0));
out.addProperty("end_reason", row.get("end_reason").getAsString());
return out;
}
}
Thread.sleep(2000);
}
return JsonNull.INSTANCE;
}
// The viewer: JPEG frames (binary) and JSON messages; "pages" says which URL is on screen.
static final List<String> SEEN = new CopyOnWriteArrayList<>();
static final AtomicInteger FRAMES = new AtomicInteger();
static class Viewer implements WebSocket.Listener {
final StringBuilder text = new StringBuilder();
public CompletionStage<?> onBinary(WebSocket ws, ByteBuffer data, boolean last) {
if (last) FRAMES.incrementAndGet();
ws.request(1);
return null;
}
public void onError(WebSocket ws, Throwable error) {
System.err.println("viewer: " + error);
}
public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
text.append(data);
if (last) {
JsonObject m = JsonParser.parseString(text.toString()).getAsJsonObject();
text.setLength(0);
if (m.has("type") && m.get("type").getAsString().equals("pages")) {
JsonElement current = m.get("current");
for (JsonElement e : m.getAsJsonArray("pages")) {
JsonObject pg = e.getAsJsonObject();
if (pg.get("targetId").equals(current) && pg.has("url") && !pg.get("url").isJsonNull()) {
String url = pg.get("url").getAsString();
if (SEEN.isEmpty() || !SEEN.get(SEEN.size() - 1).equals(url)) SEEN.add(url);
}
}
}
}
ws.request(1);
return null;
}
}
static boolean waitForUrl(String part) throws Exception {
for (int i = 0; i < 60; i++) {
if (SEEN.stream().anyMatch(u -> u.contains(part))) return true;
Thread.sleep(500);
}
return SEEN.stream().anyMatch(u -> u.contains(part));
}
static void send(WebSocket ws, JsonObject m) {
ws.sendText(m.toString(), true).join();
}
static void key(WebSocket ws, String k) {
for (String action : List.of("keyDown", "keyUp")) {
JsonObject m = new JsonObject();
m.addProperty("type", "key");
m.addProperty("action", action);
m.addProperty("key", k);
m.addProperty("code", k);
send(ws, m);
}
}
public static void main(String[] args) throws Exception {
// 1. Launch with a start page and no client: the browser opens it by itself. A twin session
// with the same options gets no viewer at all, to compare with.
JsonObject body = new JsonObject();
body.addProperty("proxy", PROXY);
body.addProperty("headless", true);
body.addProperty("live_view", true);
body.addProperty("record", false);
body.addProperty("start_url", "https://httpbin.org/");
body.addProperty("inactivity_timeout", "30s");
JsonObject session = launch("chromium", body);
JsonObject twin = launch("chromium", body);
String wsUrl = session.get("wsUrl").getAsString();
// 2. A live link in control mode — what "Launch interactive" in the dashboard opens for you.
JsonObject req = new JsonObject();
req.addProperty("session_id", session.get("sessionId").getAsString());
req.addProperty("mode", "control");
JsonObject live = post("https://" + URI.create(wsUrl).getHost() + "/admin/session/live", req).getAsJsonObject();
WebSocket viewer = HTTP.newWebSocketBuilder().buildAsync(URI.create(live.get("live_url").getAsString()), new Viewer()).join();
// 3. The person (here a scripted stand-in): goes to the form from the address bar, then fills
// the first three fields from the keyboard — Tab to a field, type.
boolean startShown = waitForUrl("httpbin.org");
JsonObject nav = new JsonObject();
nav.addProperty("type", "navigate");
nav.addProperty("url", "https://httpbin.org/forms/post");
send(viewer, nav);
boolean formShown = waitForUrl("/forms/post");
Thread.sleep(1500);
for (String text : List.of("Ada Lovelace", "+44 20 7946 0000", "[email protected]")) {
key(viewer, "Tab");
Thread.sleep(200);
JsonObject t = new JsonObject();
t.addProperty("type", "text");
t.addProperty("text", text);
send(viewer, t);
Thread.sleep(300);
}
// 4. Then only watches — no input for 45 s, past the 30 s inactivity_timeout.
Thread.sleep(45000);
JsonObject out = new JsonObject();
try (Playwright pw = Playwright.create()) {
Map<String, String> headers = Map.of("x-api-key", KEY);
// The unwatched twin has had no input and no viewer for as long: it should be gone.
boolean twinAlive = true;
try {
pw.chromium().connect(twin.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(headers).setTimeout(15000)).close();
} catch (PlaywrightException e) {
twinAlive = false;
}
// 5. Your code joins the same browser and finds the person's page in the default context.
Browser browser = pw.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(headers));
out.addProperty("live_protocol", live.get("protocol").getAsString());
out.addProperty("start_page_shown", startShown);
out.addProperty("form_shown", formShown);
out.addProperty("frames_while_by_hand", FRAMES.get());
out.addProperty("watched_session_alive_after_45s", browser.isConnected());
out.addProperty("unwatched_twin_alive_after_45s", twinAlive);
try {
List<Page> pages = browser.contexts().get(0).pages();
Page page = pages.stream().filter(pg -> pg.url().contains("/forms/post")).findFirst().orElseThrow();
JsonArray found = new JsonArray();
pages.forEach(pg -> found.add(pg.url()));
out.add("pages_found", found);
JsonObject typed = new JsonObject();
for (String name : List.of("custname", "custtel", "custemail")) typed.addProperty(name, page.inputValue("input[name=" + name + "]"));
out.add("typed_by_hand", typed);
// 6. The code finishes the job: the choices a script makes reliably, then submit.
page.check("input[name=size][value=medium]");
page.check("input[name=topping][value=cheese]");
page.fill("input[name=delivery]", "19:30");
page.waitForNavigation(new Page.WaitForNavigationOptions().setUrl("**/post"), () -> page.click("form button"));
out.add("submitted", JsonParser.parseString(page.textContent("body")).getAsJsonObject().get("form"));
Thread.sleep(1500);
JsonArray saw = new JsonArray();
SEEN.forEach(saw::add);
out.add("viewer_saw", saw);
} finally {
viewer.sendClose(WebSocket.NORMAL_CLOSURE, "").join();
browser.close();
}
}
// 7. The account API's session history.
out.add("watched_session", history(session.get("sessionId").getAsString()));
out.add("unwatched_twin", history(twin.get("sessionId").getAsString()));
System.out.println(GSON.toJson(out));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL (your own proxy; a few KB of traffic)
using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL")!;
var headers = new Dictionary<string, string> { ["x-api-key"] = key };
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<JsonElement> Launch(string engine, JsonObject body)
{
for (var attempt = 1; ; attempt++)
{
var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session", new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
if (res.IsSuccessStatusCode) return await res.Content.ReadFromJsonAsync<JsonElement>();
if ((int)res.StatusCode != 503 || attempt == 5) throw new Exception($"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? 5000));
}
}
// The account API's session history: how long each session ran and why it ended.
async Task<JsonNode?> History(string sessionId)
{
var since = Uri.EscapeDataString(DateTime.UtcNow.AddHours(-1).ToString("yyyy-MM-ddTHH:mm:ssZ"));
for (var i = 0; i < 30; i++)
{
var rows = (await http.GetFromJsonAsync<JsonObject>($"https://cdpfleet.com/v1/me/sessions?limit=50&from={since}"))!["sessions"]!.AsArray();
var row = rows.FirstOrDefault(r => (string?)r!["session_id"] == sessionId);
if (row?["ended_at"] is JsonNode ended)
{
var seconds = (DateTimeOffset.Parse((string)ended!) - DateTimeOffset.Parse((string)row["started_at"]!)).TotalSeconds;
return new JsonObject { ["seconds"] = (long)Math.Round(seconds), ["end_reason"] = (string?)row["end_reason"] };
}
await Task.Delay(2000);
}
return null;
}
// 1. Launch with a start page and no client: the browser opens it by itself. A twin session
// with the same options gets no viewer at all, to compare with.
JsonObject Body() => new() { ["proxy"] = proxy, ["headless"] = true, ["live_view"] = true, ["record"] = false, ["start_url"] = "https://httpbin.org/", ["inactivity_timeout"] = "30s" };
var session = await Launch("chromium", Body());
var twin = await Launch("chromium", Body());
var wsUrl = session.GetProperty("wsUrl").GetString()!;
// 2. A live link in control mode — what "Launch interactive" in the dashboard opens for you.
var linkRes = await http.PostAsync($"https://{new Uri(wsUrl).Host}/admin/session/live",
new StringContent(new JsonObject { ["session_id"] = session.GetProperty("sessionId").GetString(), ["mode"] = "control" }.ToJsonString(), Encoding.UTF8, "application/json"));
var live = (await linkRes.Content.ReadFromJsonAsync<JsonObject>())!;
// The viewer: JPEG frames (binary) and JSON messages; "pages" says which URL is on screen.
using var viewer = new ClientWebSocket();
await viewer.ConnectAsync(new Uri((string)live["live_url"]!), CancellationToken.None);
var seen = new List<string>();
var frames = 0;
var reader = Task.Run(async () =>
{
var buffer = new byte[1 << 20];
while (viewer.State == WebSocketState.Open)
{
using var ms = new MemoryStream();
WebSocketReceiveResult r;
try
{
do { r = await viewer.ReceiveAsync(buffer, CancellationToken.None); ms.Write(buffer, 0, r.Count); } while (!r.EndOfMessage);
}
catch (WebSocketException) { return; }
if (r.MessageType == WebSocketMessageType.Binary) { frames++; continue; }
if (r.MessageType != WebSocketMessageType.Text) return;
var m = JsonNode.Parse(Encoding.UTF8.GetString(ms.ToArray()))!;
if ((string?)m["type"] != "pages") continue;
var current = (string?)m["current"];
var url = m["pages"]!.AsArray().Where(p => (string?)p!["targetId"] == current).Select(p => (string?)p!["url"]).FirstOrDefault();
lock (seen) { if (url != null && (seen.Count == 0 || seen[^1] != url)) seen.Add(url); }
}
});
Task Send(JsonObject m) => viewer.SendAsync(Encoding.UTF8.GetBytes(m.ToJsonString()), WebSocketMessageType.Text, true, CancellationToken.None);
bool Seen(string part) { lock (seen) return seen.Any(u => u.Contains(part)); }
async Task<bool> WaitForUrl(string part) { for (var i = 0; i < 60 && !Seen(part); i++) await Task.Delay(500); return Seen(part); }
async Task Key(string k)
{
await Send(new JsonObject { ["type"] = "key", ["action"] = "keyDown", ["key"] = k, ["code"] = k });
await Send(new JsonObject { ["type"] = "key", ["action"] = "keyUp", ["key"] = k, ["code"] = k });
}
// 3. The person (here a scripted stand-in): goes to the form from the address bar, then fills
// the first three fields from the keyboard — Tab to a field, type.
var startShown = await WaitForUrl("httpbin.org");
await Send(new JsonObject { ["type"] = "navigate", ["url"] = "https://httpbin.org/forms/post" });
var formShown = await WaitForUrl("/forms/post");
await Task.Delay(1500);
foreach (var text in new[] { "Ada Lovelace", "+44 20 7946 0000", "[email protected]" })
{
await Key("Tab");
await Task.Delay(200);
await Send(new JsonObject { ["type"] = "text", ["text"] = text });
await Task.Delay(300);
}
// 4. Then only watches — no input for 45 s, past the 30 s inactivity_timeout.
await Task.Delay(45000);
// The unwatched twin has had no input and no viewer for as long: it should be gone.
var twinAlive = true;
try { await (await playwright.Chromium.ConnectAsync(twin.GetProperty("wsUrl").GetString()!, new() { Headers = headers, Timeout = 15000 })).CloseAsync(); }
catch (PlaywrightException) { twinAlive = false; }
// 5. Your code joins the same browser and finds the person's page in the default context.
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = headers });
var output = new JsonObject
{
["live_protocol"] = (string?)live["protocol"],
["start_page_shown"] = startShown,
["form_shown"] = formShown,
["frames_while_by_hand"] = frames,
["watched_session_alive_after_45s"] = browser.IsConnected,
["unwatched_twin_alive_after_45s"] = twinAlive,
};
try
{
var pages = browser.Contexts[0].Pages;
var page = pages.First(p => p.Url.Contains("/forms/post"));
output["pages_found"] = new JsonArray(pages.Select(p => (JsonNode)p.Url!).ToArray());
var typed = new JsonObject();
foreach (var name in new[] { "custname", "custtel", "custemail" }) typed[name] = await page.InputValueAsync($"input[name={name}]");
output["typed_by_hand"] = typed;
// 6. The code finishes the job: the choices a script makes reliably, then submit.
await page.CheckAsync("input[name=size][value=medium]");
await page.CheckAsync("input[name=topping][value=cheese]");
await page.FillAsync("input[name=delivery]", "19:30");
await page.RunAndWaitForNavigationAsync(() => page.ClickAsync("form button"), new() { UrlString = "**/post" });
output["submitted"] = JsonNode.Parse((await page.TextContentAsync("body"))!)!["form"]!.DeepClone();
await Task.Delay(1500);
lock (seen) output["viewer_saw"] = new JsonArray(seen.Select(u => (JsonNode)u!).ToArray());
}
finally
{
try { await viewer.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); } catch (Exception) { }
await browser.CloseAsync();
}
// 7. The account API's session history.
output["watched_session"] = await History(session.GetProperty("sessionId").GetString()!);
output["unwatched_twin"] = await History(twin.GetProperty("sessionId").GetString()!);
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// go get github.com/playwright-community/[email protected] github.com/gorilla/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL (your own proxy; a few KB of traffic)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/playwright-community/playwright-go"
)
var (
key = os.Getenv("CDPFLEET_API_KEY")
proxy = os.Getenv("PROXY_URL")
)
func call(method, u string, body any) (*http.Response, []byte, error) {
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, u, r)
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, nil, err
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
return res, data, err
}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(engine string, opts map[string]any) map[string]any {
for attempt := 1; ; attempt++ {
res, data, err := call("POST", "https://starter.cdpfleet.com/"+engine+"/session", opts)
if err != nil {
log.Fatal(err)
}
if res.StatusCode == 200 {
var out map[string]any
json.Unmarshal(data, &out)
return out
}
if res.StatusCode != 503 || attempt == 5 {
log.Fatalf("launch %d %s", res.StatusCode, data)
}
wait, err := strconv.Atoi(res.Header.Get("retry-after"))
if err != nil {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
// The account API's session history: how long each session ran and why it ended.
func history(sessionID string) any {
since := url.QueryEscape(time.Now().UTC().Add(-time.Hour).Format(time.RFC3339))
for i := 0; i < 30; i++ {
_, data, err := call("GET", "https://cdpfleet.com/v1/me/sessions?limit=50&from="+since, nil)
if err != nil {
log.Fatal(err)
}
var list struct {
Sessions []struct {
SessionID string `json:"session_id"`
StartedAt string `json:"started_at"`
EndedAt *string `json:"ended_at"`
EndReason string `json:"end_reason"`
} `json:"sessions"`
}
json.Unmarshal(data, &list)
for _, s := range list.Sessions {
if s.SessionID == sessionID && s.EndedAt != nil {
start, _ := time.Parse(time.RFC3339, s.StartedAt)
end, _ := time.Parse(time.RFC3339, *s.EndedAt)
return map[string]any{"seconds": int(math.Round(end.Sub(start).Seconds())), "end_reason": s.EndReason}
}
}
time.Sleep(2 * time.Second)
}
return nil
}
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
// 1. Launch with a start page and no client: the browser opens it by itself. A twin session
// with the same options gets no viewer at all, to compare with.
body := map[string]any{"proxy": proxy, "headless": true, "live_view": true, "record": false,
"start_url": "https://httpbin.org/", "inactivity_timeout": "30s"}
session := launch("chromium", body)
twin := launch("chromium", body)
wsURL := session["wsUrl"].(string)
// 2. A live link in control mode — what "Launch interactive" in the dashboard opens for you.
u := must(url.Parse(wsURL))
_, data, err := call("POST", "https://"+u.Host+"/admin/session/live", map[string]any{"session_id": session["sessionId"], "mode": "control"})
if err != nil {
log.Fatal(err)
}
var live struct {
LiveURL string `json:"live_url"`
Protocol string `json:"protocol"`
}
json.Unmarshal(data, &live)
// The viewer: JPEG frames (binary) and JSON messages; "pages" says which URL is on screen.
viewer, _, err := websocket.DefaultDialer.Dial(live.LiveURL, nil)
if err != nil {
log.Fatal(err)
}
var mu sync.Mutex
seen := []string{}
frames := 0
go func() {
for {
kind, msg, err := viewer.ReadMessage()
if err != nil {
return
}
mu.Lock()
if kind == websocket.BinaryMessage {
frames++
} else {
var m struct {
Type string `json:"type"`
Current string `json:"current"`
Pages []struct {
TargetID string `json:"targetId"`
URL string `json:"url"`
} `json:"pages"`
}
if json.Unmarshal(msg, &m) == nil && m.Type == "pages" {
for _, p := range m.Pages {
if p.TargetID == m.Current && p.URL != "" && (len(seen) == 0 || seen[len(seen)-1] != p.URL) {
seen = append(seen, p.URL)
}
}
}
}
mu.Unlock()
}
}()
send := func(m map[string]any) {
if err := viewer.WriteJSON(m); err != nil {
log.Fatal(err)
}
}
has := func(part string) bool {
mu.Lock()
defer mu.Unlock()
for _, s := range seen {
if strings.Contains(s, part) {
return true
}
}
return false
}
waitForURL := func(part string) bool {
for i := 0; i < 60 && !has(part); i++ {
time.Sleep(500 * time.Millisecond)
}
return has(part)
}
pressKey := func(k string) {
send(map[string]any{"type": "key", "action": "keyDown", "key": k, "code": k})
send(map[string]any{"type": "key", "action": "keyUp", "key": k, "code": k})
}
// 3. The person (here a scripted stand-in): goes to the form from the address bar, then fills
// the first three fields from the keyboard — Tab to a field, type.
startShown := waitForURL("httpbin.org")
send(map[string]any{"type": "navigate", "url": "https://httpbin.org/forms/post"})
formShown := waitForURL("/forms/post")
time.Sleep(1500 * time.Millisecond)
for _, text := range []string{"Ada Lovelace", "+44 20 7946 0000", "[email protected]"} {
pressKey("Tab")
time.Sleep(200 * time.Millisecond)
send(map[string]any{"type": "text", "text": text})
time.Sleep(300 * time.Millisecond)
}
// 4. Then only watches — no input for 45 s, past the 30 s inactivity_timeout.
time.Sleep(45 * time.Second)
pw := must(playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}))
defer pw.Stop()
headers := map[string]string{"x-api-key": key}
// The unwatched twin has had no input and no viewer for as long: it should be gone.
twinAlive := true
if b, err := pw.Chromium.Connect(twin["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: headers, Timeout: playwright.Float(15000)}); err != nil {
twinAlive = false
} else {
b.Close()
}
// 5. Your code joins the same browser and finds the person's page in the default context.
browser := must(pw.Chromium.Connect(wsURL, playwright.BrowserTypeConnectOptions{Headers: headers}))
mu.Lock()
out := map[string]any{
"live_protocol": live.Protocol, "start_page_shown": startShown, "form_shown": formShown, "frames_while_by_hand": frames,
"watched_session_alive_after_45s": browser.IsConnected(), "unwatched_twin_alive_after_45s": twinAlive,
}
mu.Unlock()
func() {
defer browser.Close()
defer viewer.Close()
pages := browser.Contexts()[0].Pages()
var page playwright.Page
found := []string{}
for _, p := range pages {
found = append(found, p.URL())
if strings.Contains(p.URL(), "/forms/post") {
page = p
}
}
out["pages_found"] = found
typed := map[string]string{}
for _, name := range []string{"custname", "custtel", "custemail"} {
typed[name] = must(page.InputValue("input[name=" + name + "]"))
}
out["typed_by_hand"] = typed
// 6. The code finishes the job: the choices a script makes reliably, then submit.
must(0, page.Check("input[name=size][value=medium]"))
must(0, page.Check("input[name=topping][value=cheese]"))
must(0, page.Fill("input[name=delivery]", "19:30"))
// No URL pattern: playwright-go v0.6000 can't match URLs in a context it didn't create (nil options).
must(page.ExpectNavigation(func() error { return page.Click("form button") }))
var echo map[string]any
must(0, json.Unmarshal([]byte(must(page.TextContent("body"))), &echo))
out["submitted"] = echo["form"]
time.Sleep(1500 * time.Millisecond)
mu.Lock()
out["viewer_saw"] = append([]string{}, seen...)
mu.Unlock()
}()
// 7. The account API's session history.
out["watched_session"] = history(session["sessionId"].(string))
out["unwatched_twin"] = history(twin["sessionId"].(string))
printed, _ := json.MarshalIndent(out, "", " ")
fmt.Println(string(printed))
}
What we got
| What | Result |
|---|---|
| Live link protocol | screencast |
| Pages seen by the viewer | / → /forms/post → /post |
| Typed in the live view (read by code) | Ada Lovelace · +44 20 7946 0000 · [email protected] |
| Submitted by code | size medium, topping cheese, delivery 19:30 |
| Watched session after 45 s without input | alive |
| Unwatched twin after 45 s | ended |
| Watched session ran / ended because | 68 s / client_disconnected |
| Unwatched twin ran | 30 s |
From the Node.js run on 2026-10-17. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
{
"live_protocol": "screencast",
"start_page_shown": true,
"form_shown": true,
"frames_while_by_hand": 142,
"watched_session_alive_after_45s": true,
"unwatched_twin_alive_after_45s": false,
"pages_found": [
"https://httpbin.org/forms/post"
],
"typed_by_hand": {
"custname": "Ada Lovelace",
"custtel": "+44 20 7946 0000",
"custemail": "[email protected]"
},
"submitted": {
"comments": "",
"custemail": "[email protected]",
"custname": "Ada Lovelace",
"custtel": "+44 20 7946 0000",
"delivery": "19:30",
"size": "medium",
"topping": "cheese"
},
"viewer_saw": [
"https://httpbin.org/",
"https://httpbin.org/forms/post",
"https://httpbin.org/post"
],
"watched_session": {
"seconds": 68,
"end_reason": "client_disconnected"
},
"unwatched_twin": {
"seconds": 30,
"end_reason": "node_closed"
}
}Takeaways
- Your code lands on the person's page:
contexts()[0].pages()held exactly one page, the form, with the name, phone and e-mail typed in the live view. The code read them and submitted the order; httpbin received all seven fields. - The viewer follows the code too: the live view's
pagesmessages went from the start page to the form (the person) to/post(the code), so whoever is watching sees the script take over. - An open viewer counts as activity: 45 s without input didn't end the watched session, though its
inactivity_timeoutwas 30 s. The unwatched twin, launched with the same options, ended at 30 s and refused the connection. - Disconnecting ends it: without
keep_alive, the session ended when the code closed its connection (client_disconnected) — 50–80 s after launch in these runs, well past the 30 s idle timeout. Billing is that of a normal session. - Use it when a step needs a person or you're still exploring; keep the parts a script does reliably — choices, submits, extraction — in code. In the dashboard the same flow is Code generator → Try it live → Launch interactive, with the generated code for the identical browser next to it.