Examples & benchmarks / #82 · 2026-11-04 · Example · Easy · for ai agents, web scraping
Kept downloads in practice: a person in Launch interactive and an AI agent over MCP
Files nobody's code was waiting for: a person pastes file links into a Launch interactive session, an AI agent clicks a download and opens a file link through the hosted MCP server. Both sessions run with "downloads": true; afterwards the files are listed, fetched and checked byte for byte against the originals — and the program times how long after the session ends they show up.
Run on production on 2026-11-04: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Playwright's download.saveAs() only works while your code is connected and waiting for the file. Many downloads happen when it isn't: a person clicking "Export" in live view or in a Launch interactive session, an AI assistant working through the MCP server, a script that already disconnected. With "downloads": true the session keeps every file it downloads for 2 days. This case does both hand-driven and agent-driven downloads for real, then collects the files the way you would afterwards: list them, fetch them through their private links, and check that the bytes are the originals.
What we used, and why
| What | Why |
|---|---|
"downloads": true | Keeps every file the session downloads, for 2 days (paid plans). In MCP it is the downloads argument of launch_browser. |
start_url + live_view | Launch interactive from code: the server opens the page itself, no client connects, and a control live link lets a person drive it. |
The live link's navigate message | What the viewer's address bar sends. The person here is a scripted stand-in that pastes two file links. |
DELETE /v1/me/threads/{id} | Ends the session — what the dashboard does when you close a Launch interactive viewer. |
https://cdpfleet.com/mcp | The hosted MCP server, with the API key as Authorization: Bearer, as in MCP in action: launch_browser, navigate, click, evaluate, close_browser, then the downloads tool. |
GET /v1/me/downloads?session_id=…, GET /v1/me/downloads/{id}/url | The session's kept files with bytes, sha256 and source_url, and a private link per file (15 minutes). |
httpbingo.org /bytes/{n}?seed=… | Test files whose bytes depend only on the URL, so each kept copy can be compared with the original fetched directly. |
How it works
- Launch headless Chromium with
downloads,live_viewandstart_url: https://httpbingo.org/, and no client. Get a control live link and open it. - The person pastes two file links into the address bar (200 KB and 64 KB), 3 seconds apart, then closes the viewer; the session is ended with
DELETE /v1/me/threads/{id}. - Poll
GET /v1/me/downloads?session_id=…until both files are listed, fetch each throughGET /v1/me/downloads/{id}/url, and compare size and sha256 with the list and with the original. - Over MCP: launch Chromium with
downloads: true, open httpbingo.org, click its/bytes/:nexample link,navigateto a 100 KB file link, read where the page is, andclose_browsertwo seconds later. - Call the
downloadstool until it lists both files, fetch them through the links it returns, and check them against the account API's list and the original.
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 ws @modelcontextprotocol/[email protected]
// env: CDPFLEET_API_KEY (a paid plan: kept downloads aren't on Community), PROXY_URL
import { createHash } from 'node:crypto';
import WebSocket from 'ws';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL;
const API = 'https://cdpfleet.com';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
// Seeded test files: the same URL always returns the same bytes, so a copy can be checked
// against the original fetched directly from here.
const PERSON_FILES = ['https://httpbingo.org/bytes/204800?seed=82', 'https://httpbingo.org/bytes/65536?seed=7'];
const AGENT_FILE = 'https://httpbingo.org/bytes/102400?seed=79';
async function api(method, path) {
const res = await fetch(`${API}${path}`, { method, headers: { 'x-api-key': KEY } });
return { status: res.status, body: await res.json() };
}
// Poll the account API until the session's kept files are listed; ms from `since`.
async function listed(sessionId, count, since) {
for (;;) {
const { body } = await api('GET', `/v1/me/downloads?session_id=${sessionId}`);
if (body.files.length >= count || Date.now() - since > 120000) return { files: body.files, ms: Date.now() - since };
await sleep(250);
}
}
// Fetch each kept file through its private link and compare it with what the list said and,
// for seeded files, with the original.
async function fetchKept(files, links = {}) {
const out = [];
for (const f of [...files].sort((a, b) => b.bytes - a.bytes)) {
let url = links[f.name];
let expires = null;
if (!url) ({ url, expires_in_s: expires } = (await api('GET', `/v1/me/downloads/${f.id}/url`)).body);
const res = await fetch(url);
const got = Buffer.from(await res.arrayBuffer());
const seeded = /[?&]seed=/.test(f.source_url || '');
const original = seeded ? Buffer.from(await (await fetch(f.source_url)).arrayBuffer()) : null;
out.push({
name: f.name, bytes: f.bytes, sha256: f.sha256, source_url: f.source_url,
link: links[f.name] ? 'from the downloads tool' : `GET /v1/me/downloads/{id}/url (${expires} s)`,
fetched_status: res.status, fetched_type: res.headers.get('content-type'), fetched_bytes: got.length, fetched_sha256: sha256(got),
original_bytes: original?.length ?? null, original_sha256: original ? sha256(original) : null,
});
}
return out;
}
// ---- 1. A person in Launch interactive: no client, the server opens the start page and
// a person drives it through a control live link (a scripted stand-in here).
async function person() {
const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: PROXY, headless: true, live_view: true, record: false, downloads: true, start_url: 'https://httpbingo.org/', inactivity_timeout: 60 }),
});
if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
const session = await res.json();
const live = await (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' }),
})).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') return;
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); });
for (let i = 0; i < 60 && !seen.includes('https://httpbingo.org/'); i++) await sleep(500);
const startShown = seen.includes('https://httpbingo.org/');
// The person pastes two file links into the address bar, a few seconds apart.
for (const url of PERSON_FILES) {
viewer.send(JSON.stringify({ type: 'navigate', url }));
await sleep(3000);
}
const pageAfter = seen.at(-1);
viewer.close();
// Closing the dashboard's viewer ends a Launch interactive session: DELETE /v1/me/threads/{id}.
const t = Date.now();
const end = await api('DELETE', `/v1/me/threads/${session.sessionId}`);
const ended = Date.now();
const { files, ms } = await listed(session.sessionId, PERSON_FILES.length, ended);
return {
launch_downloads: session.downloads, resource_class: session.resource_class, live_protocol: live.protocol,
start_page_shown: startShown, frames, pasted: PERSON_FILES, page_after: pageAfter,
end_status: end.status, end_ms: ended - t, listed_after_ms: ms, files: await fetchKept(files),
};
}
// ---- 2. An AI agent through the hosted MCP server, authenticated with the API key.
async function agent() {
const transport = new StreamableHTTPClientTransport(new URL(`${API}/mcp`), { requestInit: { headers: { authorization: `Bearer ${KEY}` } } });
const client = new Client({ name: 'cdpfleet-case-82', version: '1.0.0' });
await client.connect(transport);
const calls = [];
const call = async (name, args = {}) => {
const t = Date.now();
const r = await client.callTool({ name, arguments: args });
const text = r.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n');
calls.push({ tool: name, ms: Date.now() - t, error: Boolean(r.isError), text: text.split('\n')[0].slice(0, 160) });
return text;
};
const launched = await call('launch_browser', { browser: 'chromium', headless: true, proxy: PROXY, record: false, downloads: true });
const sessionId = /session ([0-9a-f-]{36})/.exec(launched)?.[1] ?? null;
await call('navigate', { url: 'https://httpbingo.org/' });
await call('click', { role: 'link', name: '/bytes/:n' }); // the index page's example link: 1,024 random bytes
await call('navigate', { url: AGENT_FILE }); // a file link opened directly
await call('evaluate', { expression: 'location.href' });
await sleep(2000); // an assistant's next call comes seconds later; let the downloads finish
await call('close_browser');
// Ask the downloads tool until both files are there.
const closed = Date.now();
let text = '';
let polls = 0;
for (; Date.now() - closed < 120000; polls++) {
text = await call('downloads');
if ((text.match(/ bytes, sha256 /g) || []).length >= 2) break;
await sleep(500);
}
const toolMs = Date.now() - closed;
// "<name> — <bytes> bytes, sha256 <hex>" and a private link on the next line.
const tool = [...text.matchAll(/^(.+) — (\d+) bytes, sha256 ([0-9a-f]{64})\n {2}(https:\S+)/gm)].map((m) => ({ name: m[1], bytes: Number(m[2]), sha256: m[3], link: m[4] }));
await transport.terminateSession();
await client.close();
const { files } = await listed(sessionId, 2, Date.now());
const links = Object.fromEntries(tool.map((f) => [f.name, f.link]));
return {
session_id_found: Boolean(sessionId),
calls: calls.filter((c) => c.tool !== 'downloads' || c === calls.at(-1)).map((c) => (c.tool === 'downloads' ? { ...c, polls: polls + 1 } : c)),
tool_listed_after_ms: toolMs,
first_downloads_call: calls.find((c) => c.tool === 'downloads').text,
tool_files: tool.map(({ link, ...f }) => ({ ...f, link_host: new URL(link).host })),
files: await fetchKept(files, links),
};
}
const personOut = await person();
const agentOut = await agent();
console.log(JSON.stringify({ language: 'node', person: personOut, agent: agentOut, measured_at: new Date().toISOString().slice(0, 10) }, null, 2));
# pip install aiohttp mcp==2.3.0
# env: CDPFLEET_API_KEY (a paid plan: kept downloads aren't on Community), PROXY_URL
import asyncio
import hashlib
import json
import os
import re
import time
from urllib.parse import urlparse
import aiohttp
from mcp import ClientSession
from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
KEY = os.environ["CDPFLEET_API_KEY"]
PROXY = os.environ["PROXY_URL"]
API = "https://cdpfleet.com"
# Seeded test files: the same URL always returns the same bytes, so a copy can be checked
# against the original fetched directly from here.
PERSON_FILES = ["https://httpbingo.org/bytes/204800?seed=82", "https://httpbingo.org/bytes/65536?seed=7"]
AGENT_FILE = "https://httpbingo.org/bytes/102400?seed=79"
def now_ms():
return round(time.monotonic() * 1000)
async def api(http, method, path):
async with http.request(method, API + path, headers={"x-api-key": KEY}) as r:
return r.status, await r.json()
async def listed(http, session_id, count, since):
"""Poll the account API until the session's kept files are listed; ms from `since`."""
while True:
_, body = await api(http, "GET", f"/v1/me/downloads?session_id={session_id}")
if len(body["files"]) >= count or now_ms() - since > 120000:
return body["files"], now_ms() - since
await asyncio.sleep(0.25)
async def fetch_kept(http, files, links=None):
"""Fetch each kept file through its private link and compare it with what the list said and,
for seeded files, with the original."""
links = links or {}
out = []
for f in sorted(files, key=lambda f: -f["bytes"]):
url, expires = links.get(f["name"]), None
if not url:
_, link = await api(http, "GET", f"/v1/me/downloads/{f['id']}/url")
url, expires = link["url"], link["expires_in_s"]
async with http.get(url) as r:
got, status, ctype = await r.read(), r.status, r.headers.get("content-type")
original = None
if re.search(r"[?&]seed=", f.get("source_url") or ""):
async with http.get(f["source_url"]) as r:
original = await r.read()
out.append({
"name": f["name"], "bytes": f["bytes"], "sha256": f["sha256"], "source_url": f["source_url"],
"link": "from the downloads tool" if f["name"] in links else f"GET /v1/me/downloads/{{id}}/url ({expires} s)",
"fetched_status": status, "fetched_type": ctype, "fetched_bytes": len(got), "fetched_sha256": hashlib.sha256(got).hexdigest(),
"original_bytes": len(original) if original is not None else None,
"original_sha256": hashlib.sha256(original).hexdigest() if original is not None else None,
})
return out
# ---- 1. A person in Launch interactive: no client, the server opens the start page and
# a person drives it through a control live link (a scripted stand-in here).
async def person(http):
body = {"proxy": PROXY, "headless": True, "live_view": True, "record": False, "downloads": True,
"start_url": "https://httpbingo.org/", "inactivity_timeout": 60}
async with http.post("https://starter.cdpfleet.com/chromium/session", json=body, headers={"x-api-key": KEY}) as r:
if not r.ok:
raise RuntimeError(f"launch {r.status} {await r.text()}")
session = await r.json()
host = urlparse(session["wsUrl"]).netloc
async with http.post(f"https://{host}/admin/session/live", json={"session_id": session["sessionId"], "mode": "control"},
headers={"x-api-key": KEY}) 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"], 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())
for _ in range(60):
if "https://httpbingo.org/" in seen:
break
await asyncio.sleep(0.5)
start_shown = "https://httpbingo.org/" in seen
# The person pastes two file links into the address bar, a few seconds apart.
for url in PERSON_FILES:
await viewer.send_str(json.dumps({"type": "navigate", "url": url}))
await asyncio.sleep(3)
page_after = seen[-1] if seen else None
await viewer.close()
reader.cancel()
# Closing the dashboard's viewer ends a Launch interactive session: DELETE /v1/me/threads/{id}.
t = now_ms()
end_status, _ = await api(http, "DELETE", f"/v1/me/threads/{session['sessionId']}")
ended = now_ms()
files, ms = await listed(http, session["sessionId"], len(PERSON_FILES), ended)
return {
"launch_downloads": session.get("downloads"), "resource_class": session.get("resource_class"), "live_protocol": live["protocol"],
"start_page_shown": start_shown, "frames": frames, "pasted": PERSON_FILES, "page_after": page_after,
"end_status": end_status, "end_ms": ended - t, "listed_after_ms": ms, "files": await fetch_kept(http, files),
}
# ---- 2. An AI agent through the hosted MCP server, authenticated with the API key.
async def agent(http):
calls = []
mcp_http = create_mcp_http_client(headers={"authorization": f"Bearer {KEY}"})
async with mcp_http, streamable_http_client(f"{API}/mcp", http_client=mcp_http) as (read, write), ClientSession(read, write) as client:
await client.initialize()
async def call(name, args=None):
t = now_ms()
r = await client.call_tool(name, args or {})
text = "\n".join(c.text for c in r.content if c.type == "text")
calls.append({"tool": name, "ms": now_ms() - t, "error": bool(r.is_error), "text": text.split("\n")[0][:160]})
return text
launched = await call("launch_browser", {"browser": "chromium", "headless": True, "proxy": PROXY, "record": False, "downloads": True})
m = re.search(r"session ([0-9a-f-]{36})", launched)
session_id = m[1] if m else None
await call("navigate", {"url": "https://httpbingo.org/"})
await call("click", {"role": "link", "name": "/bytes/:n"}) # the index page's example link: 1,024 random bytes
await call("navigate", {"url": AGENT_FILE}) # a file link opened directly
await call("evaluate", {"expression": "location.href"})
await asyncio.sleep(2) # an assistant's next call comes seconds later; let the downloads finish
await call("close_browser")
# Ask the downloads tool until both files are there.
closed = now_ms()
text, polls = "", 0
while now_ms() - closed < 120000:
polls += 1
text = await call("downloads")
if len(re.findall(r" bytes, sha256 ", text)) >= 2:
break
await asyncio.sleep(0.5)
tool_ms = now_ms() - closed
# "<name> — <bytes> bytes, sha256 <hex>" and a private link on the next line.
tool = [{"name": m[1], "bytes": int(m[2]), "sha256": m[3], "link": m[4]}
for m in re.finditer(r"^(.+) — (\d+) bytes, sha256 ([0-9a-f]{64})\n {2}(https:\S+)", text, re.M)]
files, _ = await listed(http, session_id, 2, now_ms())
links = {f["name"]: f["link"] for f in tool}
last = dict(calls[-1], polls=polls)
return {
"session_id_found": session_id is not None,
"calls": [c for c in calls if c["tool"] != "downloads"] + [last],
"tool_listed_after_ms": tool_ms,
"first_downloads_call": next(c["text"] for c in calls if c["tool"] == "downloads"),
"tool_files": [{"name": f["name"], "bytes": f["bytes"], "sha256": f["sha256"], "link_host": urlparse(f["link"]).netloc} for f in tool],
"files": await fetch_kept(http, files, links),
}
async def main():
async with aiohttp.ClientSession() as http: # no default headers: the key goes only to cdpfleet
person_out = await person(http)
agent_out = await agent(http)
print(json.dumps({"language": "python", "person": person_out, "agent": agent_out,
"measured_at": time.strftime("%Y-%m-%d", time.gmtime())}, indent=2, ensure_ascii=False))
asyncio.run(main())
// Maven: io.modelcontextprotocol.sdk:mcp:2.1.0, com.google.code.gson:gson:2.11.0
// env: CDPFLEET_API_KEY (a paid plan: kept downloads aren't on Community), PROXY_URL
import com.google.gson.*;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import java.net.URI;
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.security.MessageDigest;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.*;
import java.util.stream.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String PROXY = System.getenv("PROXY_URL");
static final String API = "https://cdpfleet.com";
static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
// Seeded test files: the same URL always returns the same bytes, so a copy can be checked
// against the original fetched directly from here.
static final List<String> PERSON_FILES = List.of("https://httpbingo.org/bytes/204800?seed=82", "https://httpbingo.org/bytes/65536?seed=7");
static final String AGENT_FILE = "https://httpbingo.org/bytes/102400?seed=79";
// One HTTP request; the API key goes only to cdpfleet.
static HttpResponse<byte[]> request(String method, String url, JsonObject body, boolean withKey) throws Exception {
var b = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(120))
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body.toString()));
if (withKey) b.header("x-api-key", KEY);
if (body != null) b.header("content-type", "application/json");
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofByteArray());
}
static JsonObject json(HttpResponse<byte[]> res) { return JsonParser.parseString(new String(res.body())).getAsJsonObject(); }
static String sha256(byte[] b) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(b));
}
// Poll the account API until the session's kept files are listed; ms from `since`.
static Object[] listed(String sessionId, int count, long since) throws Exception {
while (true) {
var files = json(request("GET", API + "/v1/me/downloads?session_id=" + sessionId, null, true)).getAsJsonArray("files");
if (files.size() >= count || System.currentTimeMillis() - since > 120000) return new Object[] { files, System.currentTimeMillis() - since };
Thread.sleep(250);
}
}
// Fetch each kept file through its private link and compare it with what the list said and,
// for seeded files, with the original.
static List<Map<String, Object>> fetchKept(JsonArray files, Map<String, String> links) throws Exception {
var sorted = new ArrayList<JsonObject>();
files.forEach(e -> sorted.add(e.getAsJsonObject()));
sorted.sort(Comparator.comparingLong((JsonObject f) -> f.get("bytes").getAsLong()).reversed());
var out = new ArrayList<Map<String, Object>>();
for (var f : sorted) {
String name = f.get("name").getAsString();
String url = links.get(name);
String how = "from the downloads tool";
if (url == null) {
var link = json(request("GET", API + "/v1/me/downloads/" + f.get("id").getAsString() + "/url", null, true));
url = link.get("url").getAsString();
how = "GET /v1/me/downloads/{id}/url (" + link.get("expires_in_s").getAsInt() + " s)";
}
var res = request("GET", url, null, false);
String source = f.get("source_url").getAsString();
var c = new LinkedHashMap<String, Object>();
c.put("name", name); c.put("bytes", f.get("bytes").getAsLong()); c.put("sha256", f.get("sha256").getAsString()); c.put("source_url", source);
c.put("link", how);
c.put("fetched_status", res.statusCode()); c.put("fetched_type", res.headers().firstValue("content-type").orElse(null));
c.put("fetched_bytes", res.body().length); c.put("fetched_sha256", sha256(res.body()));
byte[] original = Pattern.compile("[?&]seed=").matcher(source).find() ? request("GET", source, null, false).body() : null;
c.put("original_bytes", original == null ? null : original.length);
c.put("original_sha256", original == null ? null : sha256(original));
out.add(c);
}
return out;
}
// 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 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")) {
for (JsonElement e : m.getAsJsonArray("pages")) {
JsonObject pg = e.getAsJsonObject();
if (pg.get("targetId").equals(m.get("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;
}
}
// ---- 1. A person in Launch interactive: no client, the server opens the start page and
// a person drives it through a control live link (a scripted stand-in here).
static Map<String, Object> person() throws Exception {
var body = new JsonObject();
body.addProperty("proxy", PROXY);
body.addProperty("headless", true);
body.addProperty("live_view", true);
body.addProperty("record", false);
body.addProperty("downloads", true);
body.addProperty("start_url", "https://httpbingo.org/");
body.addProperty("inactivity_timeout", 60);
var res = request("POST", "https://starter.cdpfleet.com/chromium/session", body, true);
if (res.statusCode() != 200) throw new RuntimeException("launch " + res.statusCode() + " " + new String(res.body()));
var session = json(res);
String sessionId = session.get("sessionId").getAsString();
var liveBody = new JsonObject();
liveBody.addProperty("session_id", sessionId);
liveBody.addProperty("mode", "control");
var live = json(request("POST", "https://" + URI.create(session.get("wsUrl").getAsString()).getHost() + "/admin/session/live", liveBody, true));
WebSocket viewer = HTTP.newWebSocketBuilder().buildAsync(URI.create(live.get("live_url").getAsString()), new Viewer()).join();
for (int i = 0; i < 60 && !SEEN.contains("https://httpbingo.org/"); i++) Thread.sleep(500);
boolean startShown = SEEN.contains("https://httpbingo.org/");
// The person pastes two file links into the address bar, a few seconds apart.
for (String url : PERSON_FILES) {
var m = new JsonObject();
m.addProperty("type", "navigate");
m.addProperty("url", url);
viewer.sendText(m.toString(), true).join();
Thread.sleep(3000);
}
String pageAfter = SEEN.isEmpty() ? null : SEEN.get(SEEN.size() - 1);
viewer.sendClose(WebSocket.NORMAL_CLOSURE, "").join();
// Closing the dashboard's viewer ends a Launch interactive session: DELETE /v1/me/threads/{id}.
long t = System.currentTimeMillis();
int endStatus = request("DELETE", API + "/v1/me/threads/" + sessionId, null, true).statusCode();
long ended = System.currentTimeMillis();
var l = listed(sessionId, PERSON_FILES.size(), ended);
var out = new LinkedHashMap<String, Object>();
out.put("launch_downloads", session.get("downloads").getAsBoolean());
out.put("resource_class", session.get("resource_class").getAsString());
out.put("live_protocol", live.get("protocol").getAsString());
out.put("start_page_shown", startShown);
out.put("frames", FRAMES.get());
out.put("pasted", PERSON_FILES);
out.put("page_after", pageAfter);
out.put("end_status", endStatus);
out.put("end_ms", ended - t);
out.put("listed_after_ms", l[1]);
out.put("files", fetchKept((JsonArray) l[0], Map.of()));
return out;
}
// ---- 2. An AI agent through the hosted MCP server, authenticated with the API key.
static Map<String, Object> agent() throws Exception {
var transport = HttpClientStreamableHttpTransport.builder(API).endpoint("/mcp")
.httpRequestCustomizer((builder, method, uri, body, ctx) -> builder.header("Authorization", "Bearer " + KEY))
.build();
McpSyncClient client = McpClient.sync(transport).requestTimeout(Duration.ofSeconds(120)).build();
client.initialize();
var calls = new ArrayList<Map<String, Object>>();
java.util.function.BiFunction<String, Map<String, Object>, String> call = (name, args) -> {
long t = System.currentTimeMillis();
var r = client.callTool(new McpSchema.CallToolRequest(name, args));
String text = r.content().stream().filter(c -> c instanceof McpSchema.TextContent)
.map(c -> ((McpSchema.TextContent) c).text()).collect(Collectors.joining("\n"));
String first = text.split("\n", 2)[0];
var e = new LinkedHashMap<String, Object>();
e.put("tool", name); e.put("ms", System.currentTimeMillis() - t); e.put("error", Boolean.TRUE.equals(r.isError()));
e.put("text", first.length() > 160 ? first.substring(0, 160) : first);
calls.add(e);
return text;
};
String launched = call.apply("launch_browser", Map.of("browser", "chromium", "headless", true, "proxy", PROXY, "record", false, "downloads", true));
var sm = Pattern.compile("session ([0-9a-f-]{36})").matcher(launched);
String sessionId = sm.find() ? sm.group(1) : null;
call.apply("navigate", Map.of("url", "https://httpbingo.org/"));
call.apply("click", Map.of("role", "link", "name", "/bytes/:n")); // the index page's example link: 1,024 random bytes
call.apply("navigate", Map.of("url", AGENT_FILE)); // a file link opened directly
call.apply("evaluate", Map.of("expression", "location.href"));
Thread.sleep(2000); // an assistant's next call comes seconds later; let the downloads finish
call.apply("close_browser", Map.of());
// Ask the downloads tool until both files are there.
long closed = System.currentTimeMillis();
String text = "";
int polls = 0;
while (System.currentTimeMillis() - closed < 120000) {
polls++;
text = call.apply("downloads", Map.of());
if (text.split(" bytes, sha256 ", -1).length - 1 >= 2) break;
Thread.sleep(500);
}
long toolMs = System.currentTimeMillis() - closed;
client.closeGracefully(); // ends the MCP session (DELETE)
// "<name> — <bytes> bytes, sha256 <hex>" and a private link on the next line.
var links = new LinkedHashMap<String, String>();
var toolFiles = new ArrayList<Map<String, Object>>();
var m = Pattern.compile("^(.+) — (\\d+) bytes, sha256 ([0-9a-f]{64})\\n {2}(https:\\S+)", Pattern.MULTILINE).matcher(text);
while (m.find()) {
links.put(m.group(1), m.group(4));
var f = new LinkedHashMap<String, Object>();
f.put("name", m.group(1)); f.put("bytes", Long.parseLong(m.group(2))); f.put("sha256", m.group(3)); f.put("link_host", URI.create(m.group(4)).getHost());
toolFiles.add(f);
}
var l = listed(sessionId, 2, System.currentTimeMillis());
var kept = calls.stream().filter(c -> !c.get("tool").equals("downloads")).collect(Collectors.toCollection(ArrayList::new));
var last = calls.get(calls.size() - 1);
last.put("polls", polls);
kept.add(last);
var out = new LinkedHashMap<String, Object>();
out.put("session_id_found", sessionId != null);
out.put("calls", kept);
out.put("tool_listed_after_ms", toolMs);
out.put("first_downloads_call", calls.stream().filter(c -> c.get("tool").equals("downloads")).findFirst().get().get("text"));
out.put("tool_files", toolFiles);
out.put("files", fetchKept((JsonArray) l[0], links));
return out;
}
public static void main(String[] args) throws Exception {
var result = new LinkedHashMap<String, Object>();
result.put("language", "java");
result.put("person", person());
result.put("agent", agent());
result.put("measured_at", java.time.LocalDate.now(java.time.ZoneOffset.UTC).toString());
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create().toJson(result));
System.exit(0); // the SDK's HTTP client keeps non-daemon threads
}
}
// dotnet add package ModelContextProtocol --version 2.3.0
// env: CDPFLEET_API_KEY (a paid plan: kept downloads aren't on Community), PROXY_URL
using System.Diagnostics;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL")!;
const string Api = "https://cdpfleet.com";
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; // the key is added per request: it goes only to cdpfleet
http.DefaultRequestHeaders.UserAgent.ParseAdd("cdpfleet-case-82/1.0"); // httpbingo.org answers 402 to requests without one
// Seeded test files: the same URL always returns the same bytes, so a copy can be checked
// against the original fetched directly from here.
string[] personFiles = ["https://httpbingo.org/bytes/204800?seed=82", "https://httpbingo.org/bytes/65536?seed=7"];
const string AgentFile = "https://httpbingo.org/bytes/102400?seed=79";
async Task<(int Status, JsonNode? Body)> ApiCall(HttpMethod method, string url, JsonObject? body = null)
{
var req = new HttpRequestMessage(method, url);
req.Headers.Add("x-api-key", key);
if (body != null) req.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
return ((int)res.StatusCode, text.Length > 0 ? JsonNode.Parse(text) : null);
}
static string Sha256(byte[] b) => Convert.ToHexString(SHA256.HashData(b)).ToLowerInvariant();
// Poll the account API until the session's kept files are listed; ms from `since`.
async Task<(JsonArray Files, long Ms)> Listed(string sessionId, int count, Stopwatch since)
{
while (true)
{
var (_, body) = await ApiCall(HttpMethod.Get, $"{Api}/v1/me/downloads?session_id={sessionId}");
var files = body!["files"]!.AsArray();
if (files.Count >= count || since.ElapsedMilliseconds > 120000) return (files, since.ElapsedMilliseconds);
await Task.Delay(250);
}
}
// Fetch each kept file through its private link and compare it with what the list said and,
// for seeded files, with the original.
async Task<List<Dictionary<string, object?>>> FetchKept(JsonArray files, Dictionary<string, string> links)
{
var output = new List<Dictionary<string, object?>>();
foreach (var f in files.OrderByDescending(f => (long)f!["bytes"]!))
{
var name = (string)f!["name"]!;
var source = (string)f["source_url"]!;
string how = "from the downloads tool";
if (!links.TryGetValue(name, out var url))
{
var (_, link) = await ApiCall(HttpMethod.Get, $"{Api}/v1/me/downloads/{(string)f["id"]!}/url");
url = (string)link!["url"]!;
how = $"GET /v1/me/downloads/{{id}}/url ({(int)link["expires_in_s"]!} s)";
}
var res = await http.GetAsync(url);
var got = await res.Content.ReadAsByteArrayAsync();
byte[]? original = Regex.IsMatch(source, "[?&]seed=") ? await http.GetByteArrayAsync(source) : null;
output.Add(new()
{
["name"] = name, ["bytes"] = (long)f["bytes"]!, ["sha256"] = (string)f["sha256"]!, ["source_url"] = source, ["link"] = how,
["fetched_status"] = (int)res.StatusCode, ["fetched_type"] = res.Content.Headers.ContentType?.ToString(),
["fetched_bytes"] = got.Length, ["fetched_sha256"] = Sha256(got),
["original_bytes"] = original?.Length, ["original_sha256"] = original == null ? null : Sha256(original),
});
}
return output;
}
// ---- 1. A person in Launch interactive: no client, the server opens the start page and
// a person drives it through a control live link (a scripted stand-in here).
async Task<Dictionary<string, object?>> Person()
{
var (status, session) = await ApiCall(HttpMethod.Post, "https://starter.cdpfleet.com/chromium/session", new JsonObject
{
["proxy"] = proxy, ["headless"] = true, ["live_view"] = true, ["record"] = false, ["downloads"] = true,
["start_url"] = "https://httpbingo.org/", ["inactivity_timeout"] = 60,
});
if (status != 200) throw new Exception($"launch {status} {session}");
var sessionId = (string)session!["sessionId"]!;
var host = new Uri((string)session["wsUrl"]!).Host;
var (_, live) = await ApiCall(HttpMethod.Post, $"https://{host}/admin/session/live", new JsonObject { ["session_id"] = sessionId, ["mode"] = "control" });
// 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) { Interlocked.Increment(ref 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); }
}
});
bool Shown() { lock (seen) return seen.Contains("https://httpbingo.org/"); }
for (var i = 0; i < 60 && !Shown(); i++) await Task.Delay(500);
var startShown = Shown();
// The person pastes two file links into the address bar, a few seconds apart.
foreach (var url in personFiles)
{
var msg = new JsonObject { ["type"] = "navigate", ["url"] = url };
await viewer.SendAsync(Encoding.UTF8.GetBytes(msg.ToJsonString()), WebSocketMessageType.Text, true, CancellationToken.None);
await Task.Delay(3000);
}
string? pageAfter;
lock (seen) pageAfter = seen.LastOrDefault();
await viewer.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
// Closing the dashboard's viewer ends a Launch interactive session: DELETE /v1/me/threads/{id}.
var sw = Stopwatch.StartNew();
var (endStatus, _) = await ApiCall(HttpMethod.Delete, $"{Api}/v1/me/threads/{sessionId}");
var endMs = sw.ElapsedMilliseconds;
var (files, listedMs) = await Listed(sessionId, personFiles.Length, Stopwatch.StartNew());
return new()
{
["launch_downloads"] = (bool?)session["downloads"], ["resource_class"] = (string?)session["resource_class"], ["live_protocol"] = (string?)live["protocol"],
["start_page_shown"] = startShown, ["frames"] = frames, ["pasted"] = personFiles, ["page_after"] = pageAfter,
["end_status"] = endStatus, ["end_ms"] = endMs, ["listed_after_ms"] = listedMs, ["files"] = await FetchKept(files, new()),
};
}
// ---- 2. An AI agent through the hosted MCP server, authenticated with the API key.
async Task<Dictionary<string, object?>> Agent()
{
var calls = new List<Dictionary<string, object?>>();
var text = "";
var polls = 0;
long toolMs;
string? sessionId;
await using (var client = await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri($"{Api}/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string> { ["Authorization"] = $"Bearer {key}" },
OwnsSession = true, // end the MCP session (DELETE) when done
})))
{
async Task<string> Call(string name, Dictionary<string, object?>? args = null)
{
var sw = Stopwatch.StartNew();
var r = await client.CallToolAsync(name, args ?? new Dictionary<string, object?>());
var t = string.Join("\n", r.Content.OfType<TextContentBlock>().Select(c => c.Text));
var first = t.Split('\n')[0];
calls.Add(new() { ["tool"] = name, ["ms"] = sw.ElapsedMilliseconds, ["error"] = r.IsError == true, ["text"] = first.Length > 160 ? first[..160] : first });
return t;
}
var launched = await Call("launch_browser", new() { ["browser"] = "chromium", ["headless"] = true, ["proxy"] = proxy, ["record"] = false, ["downloads"] = true });
var sm = Regex.Match(launched, "session ([0-9a-f-]{36})");
sessionId = sm.Success ? sm.Groups[1].Value : null;
await Call("navigate", new() { ["url"] = "https://httpbingo.org/" });
await Call("click", new() { ["role"] = "link", ["name"] = "/bytes/:n" }); // the index page's example link: 1,024 random bytes
await Call("navigate", new() { ["url"] = AgentFile }); // a file link opened directly
await Call("evaluate", new() { ["expression"] = "location.href" });
await Task.Delay(2000); // an assistant's next call comes seconds later; let the downloads finish
await Call("close_browser");
// Ask the downloads tool until both files are there.
var closed = Stopwatch.StartNew();
while (closed.ElapsedMilliseconds < 120000)
{
polls++;
text = await Call("downloads");
if (Regex.Matches(text, " bytes, sha256 ").Count >= 2) break;
await Task.Delay(500);
}
toolMs = closed.ElapsedMilliseconds;
}
// "<name> — <bytes> bytes, sha256 <hex>" and a private link on the next line.
var links = new Dictionary<string, string>();
var toolFiles = new List<Dictionary<string, object?>>();
foreach (Match m in Regex.Matches(text, @"^(.+) — (\d+) bytes, sha256 ([0-9a-f]{64})\n {2}(https:\S+)", RegexOptions.Multiline))
{
links[m.Groups[1].Value] = m.Groups[4].Value;
toolFiles.Add(new() { ["name"] = m.Groups[1].Value, ["bytes"] = long.Parse(m.Groups[2].Value), ["sha256"] = m.Groups[3].Value, ["link_host"] = new Uri(m.Groups[4].Value).Host });
}
var (files, _) = await Listed(sessionId!, 2, Stopwatch.StartNew());
var last = calls[^1];
last["polls"] = polls;
return new()
{
["session_id_found"] = sessionId != null,
["calls"] = calls.Where(c => (string)c["tool"]! != "downloads").Append(last).ToList(),
["tool_listed_after_ms"] = toolMs,
["first_downloads_call"] = calls.First(c => (string)c["tool"]! == "downloads")["text"],
["tool_files"] = toolFiles,
["files"] = await FetchKept(files, links),
};
}
var person = await Person();
var agent = await Agent();
Console.WriteLine(JsonSerializer.Serialize(new Dictionary<string, object?> { ["language"] = "csharp", ["person"] = person, ["agent"] = agent, ["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd") },
new JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// go get github.com/gorilla/[email protected]
// MCP without an SDK (the official Go SDK needs Go 1.23+): JSON-RPC 2.0 over Streamable HTTP.
// env: CDPFLEET_API_KEY (a paid plan: kept downloads aren't on Community), PROXY_URL
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
const api = "https://cdpfleet.com"
var key = os.Getenv("CDPFLEET_API_KEY")
var proxy = os.Getenv("PROXY_URL")
var httpc = &http.Client{Timeout: 120 * time.Second}
// Seeded test files: the same URL always returns the same bytes, so a copy can be checked
// against the original fetched directly from here.
var personFiles = []string{"https://httpbingo.org/bytes/204800?seed=82", "https://httpbingo.org/bytes/65536?seed=7"}
const agentFile = "https://httpbingo.org/bytes/102400?seed=79"
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
// request sends one HTTP request; the API key goes only to cdpfleet.
func request(method, u string, body any, withKey bool) (int, http.Header, []byte) {
var r io.Reader
if body != nil {
r = bytes.NewReader(must(json.Marshal(body)))
}
req := must(http.NewRequest(method, u, r))
if withKey {
req.Header.Set("x-api-key", key)
}
if body != nil {
req.Header.Set("content-type", "application/json")
}
res := must(httpc.Do(req))
defer res.Body.Close()
return res.StatusCode, res.Header, must(io.ReadAll(res.Body))
}
type File struct {
ID string `json:"id"`
Name string `json:"name"`
Bytes int `json:"bytes"`
Sha256 string `json:"sha256"`
SourceURL string `json:"source_url"`
}
type Checked struct {
Name string `json:"name"`
Bytes int `json:"bytes"`
Sha256 string `json:"sha256"`
SourceURL string `json:"source_url"`
Link string `json:"link"`
FetchedStatus int `json:"fetched_status"`
FetchedType string `json:"fetched_type"`
FetchedBytes int `json:"fetched_bytes"`
FetchedSha256 string `json:"fetched_sha256"`
OriginalBytes *int `json:"original_bytes"`
OriginalSha256 *string `json:"original_sha256"`
}
func hash(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) }
// listed polls the account API until the session's kept files are listed; ms from `since`.
func listed(sessionID string, count int, since time.Time) ([]File, int64) {
for {
_, _, data := request("GET", api+"/v1/me/downloads?session_id="+sessionID, nil, true)
var body struct {
Files []File `json:"files"`
}
json.Unmarshal(data, &body)
if len(body.Files) >= count || time.Since(since) > 2*time.Minute {
return body.Files, time.Since(since).Milliseconds()
}
time.Sleep(250 * time.Millisecond)
}
}
// fetchKept fetches each kept file through its private link and compares it with what the list
// said and, for seeded files, with the original.
func fetchKept(files []File, links map[string]string) []Checked {
sort.Slice(files, func(i, j int) bool { return files[i].Bytes > files[j].Bytes })
out := []Checked{}
for _, f := range files {
link, how := links[f.Name], "from the downloads tool"
if link == "" {
_, _, data := request("GET", api+"/v1/me/downloads/"+f.ID+"/url", nil, true)
var l struct {
URL string `json:"url"`
ExpiresIn int `json:"expires_in_s"`
}
json.Unmarshal(data, &l)
link, how = l.URL, fmt.Sprintf("GET /v1/me/downloads/{id}/url (%d s)", l.ExpiresIn)
}
status, header, got := request("GET", link, nil, false)
c := Checked{Name: f.Name, Bytes: f.Bytes, Sha256: f.Sha256, SourceURL: f.SourceURL, Link: how,
FetchedStatus: status, FetchedType: header.Get("content-type"), FetchedBytes: len(got), FetchedSha256: hash(got)}
if regexp.MustCompile(`[?&]seed=`).MatchString(f.SourceURL) {
_, _, orig := request("GET", f.SourceURL, nil, false)
nb, sum := len(orig), hash(orig)
c.OriginalBytes, c.OriginalSha256 = &nb, &sum
}
out = append(out, c)
}
return out
}
// ---- 1. A person in Launch interactive: no client, the server opens the start page and
// a person drives it through a control live link (a scripted stand-in here).
func person() map[string]any {
status, _, data := request("POST", "https://starter.cdpfleet.com/chromium/session", map[string]any{
"proxy": proxy, "headless": true, "live_view": true, "record": false, "downloads": true,
"start_url": "https://httpbingo.org/", "inactivity_timeout": 60}, true)
if status != 200 {
log.Fatalf("launch %d %s", status, data)
}
var session map[string]any
json.Unmarshal(data, &session)
sessionID := session["sessionId"].(string)
host := must(url.Parse(session["wsUrl"].(string))).Host
_, _, data = request("POST", "https://"+host+"/admin/session/live", map[string]any{"session_id": sessionID, "mode": "control"}, true)
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 := must2(websocket.DefaultDialer.Dial(live.LiveURL, nil))
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()
}
}()
shown := func() bool {
mu.Lock()
defer mu.Unlock()
for _, s := range seen {
if s == "https://httpbingo.org/" {
return true
}
}
return false
}
for i := 0; i < 60 && !shown(); i++ {
time.Sleep(500 * time.Millisecond)
}
startShown := shown()
// The person pastes two file links into the address bar, a few seconds apart.
for _, u := range personFiles {
if err := viewer.WriteJSON(map[string]any{"type": "navigate", "url": u}); err != nil {
log.Fatal(err)
}
time.Sleep(3 * time.Second)
}
mu.Lock()
pageAfter, frameCount := seen[len(seen)-1], frames
mu.Unlock()
viewer.Close()
// Closing the dashboard's viewer ends a Launch interactive session: DELETE /v1/me/threads/{id}.
t := time.Now()
endStatus, _, _ := request("DELETE", api+"/v1/me/threads/"+sessionID, nil, true)
ended := time.Now()
files, ms := listed(sessionID, len(personFiles), ended)
return map[string]any{
"launch_downloads": session["downloads"], "resource_class": session["resource_class"], "live_protocol": live.Protocol,
"start_page_shown": startShown, "frames": frameCount, "pasted": personFiles, "page_after": pageAfter,
"end_status": endStatus, "end_ms": ended.Sub(t).Milliseconds(), "listed_after_ms": ms, "files": fetchKept(files, nil),
}
}
func must2[T any, U any](v T, _ U, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
// ---- 2. An AI agent through the hosted MCP server, authenticated with the API key.
// Conn is one MCP session.
type Conn struct {
session string
nextID int
}
// post sends one JSON-RPC message and returns the result for its id (the server answers with
// JSON or with a short event stream).
func (c *Conn) post(msg map[string]any) map[string]any {
req := must(http.NewRequest("POST", api+"/mcp", bytes.NewReader(must(json.Marshal(msg)))))
req.Header.Set("authorization", "Bearer "+key)
req.Header.Set("content-type", "application/json")
req.Header.Set("accept", "application/json, text/event-stream")
req.Header.Set("mcp-protocol-version", "2025-06-18")
if c.session != "" {
req.Header.Set("mcp-session-id", c.session)
}
res := must(httpc.Do(req))
defer res.Body.Close()
if s := res.Header.Get("mcp-session-id"); s != "" {
c.session = s
}
if res.StatusCode == http.StatusAccepted { // a notification: nothing comes back
return nil
}
if res.StatusCode != http.StatusOK {
log.Fatalf("MCP HTTP %d: %s", res.StatusCode, must(io.ReadAll(res.Body)))
}
var payloads [][]byte
if strings.HasPrefix(res.Header.Get("content-type"), "text/event-stream") {
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 8<<20)
for sc.Scan() {
if line := sc.Text(); strings.HasPrefix(line, "data:") {
payloads = append(payloads, []byte(strings.TrimSpace(line[5:])))
}
}
} else {
payloads = append(payloads, must(io.ReadAll(res.Body)))
}
for _, p := range payloads {
var m map[string]any
if json.Unmarshal(p, &m) == nil && m["id"] != nil && fmt.Sprint(m["id"]) == fmt.Sprint(msg["id"]) {
if e, ok := m["error"]; ok {
log.Fatalf("JSON-RPC error: %v", e)
}
return m["result"].(map[string]any)
}
}
log.Fatalf("no response for request %v", msg["id"])
return nil
}
func (c *Conn) request(method string, params map[string]any) map[string]any {
c.nextID++
return c.post(map[string]any{"jsonrpc": "2.0", "id": c.nextID, "method": method, "params": params})
}
// terminate ends the MCP session (DELETE), as a client does when it's done.
func (c *Conn) terminate() {
req := must(http.NewRequest("DELETE", api+"/mcp", nil))
req.Header.Set("authorization", "Bearer "+key)
req.Header.Set("mcp-session-id", c.session)
if res, err := httpc.Do(req); err == nil {
res.Body.Close()
}
}
func agent() map[string]any {
c := &Conn{}
c.request("initialize", map[string]any{
"protocolVersion": "2025-06-18", "capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "cdpfleet-case-82", "version": "1.0.0"},
})
c.post(map[string]any{"jsonrpc": "2.0", "method": "notifications/initialized"})
calls := []map[string]any{}
call := func(name string, args map[string]any) string {
t := time.Now()
r := c.request("tools/call", map[string]any{"name": name, "arguments": args})
var parts []string
for _, item := range r["content"].([]any) {
if m := item.(map[string]any); m["type"] == "text" {
parts = append(parts, m["text"].(string))
}
}
text := strings.Join(parts, "\n")
isErr, _ := r["isError"].(bool)
first := strings.SplitN(text, "\n", 2)[0]
if len(first) > 160 {
first = first[:160]
}
calls = append(calls, map[string]any{"tool": name, "ms": time.Since(t).Milliseconds(), "error": isErr, "text": first})
return text
}
launched := call("launch_browser", map[string]any{"browser": "chromium", "headless": true, "proxy": proxy, "record": false, "downloads": true})
sessionID := ""
if m := regexp.MustCompile(`session ([0-9a-f-]{36})`).FindStringSubmatch(launched); m != nil {
sessionID = m[1]
}
call("navigate", map[string]any{"url": "https://httpbingo.org/"})
call("click", map[string]any{"role": "link", "name": "/bytes/:n"}) // the index page's example link: 1,024 random bytes
call("navigate", map[string]any{"url": agentFile}) // a file link opened directly
call("evaluate", map[string]any{"expression": "location.href"})
time.Sleep(2 * time.Second) // an assistant's next call comes seconds later; let the downloads finish
call("close_browser", map[string]any{})
// Ask the downloads tool until both files are there.
closed := time.Now()
text, polls := "", 0
for time.Since(closed) < 2*time.Minute {
polls++
text = call("downloads", map[string]any{})
if strings.Count(text, " bytes, sha256 ") >= 2 {
break
}
time.Sleep(500 * time.Millisecond)
}
toolMs := time.Since(closed).Milliseconds()
c.terminate()
// "<name> — <bytes> bytes, sha256 <hex>" and a private link on the next line.
links := map[string]string{}
toolFiles := []map[string]any{}
for _, m := range regexp.MustCompile(`(?m)^(.+) — (\d+) bytes, sha256 ([0-9a-f]{64})\n {2}(https:\S+)`).FindAllStringSubmatch(text, -1) {
var nb int
fmt.Sscan(m[2], &nb)
links[m[1]] = m[4]
toolFiles = append(toolFiles, map[string]any{"name": m[1], "bytes": nb, "sha256": m[3], "link_host": must(url.Parse(m[4])).Host})
}
files, _ := listed(sessionID, 2, time.Now())
kept := []map[string]any{}
for _, cl := range calls {
if cl["tool"] != "downloads" {
kept = append(kept, cl)
}
}
last := calls[len(calls)-1]
last["polls"] = polls
return map[string]any{
"session_id_found": sessionID != "", "calls": append(kept, last), "tool_listed_after_ms": toolMs,
"first_downloads_call": calls[len(kept)]["text"],
"tool_files": toolFiles, "files": fetchKept(files, links),
}
}
func main() {
p := person()
a := agent()
out, _ := json.MarshalIndent(map[string]any{"language": "go", "person": p, "agent": a, "measured_at": time.Now().UTC().Format("2006-01-02")}, "", " ")
fmt.Println(string(out))
}
What we got
| Who downloaded | How | File | Listed after the session ended | Fetched copy | Measured |
|---|---|---|---|---|---|
| a person, Launch interactive | pasted into the address bar | 204800 (200 KB), sha256 4b2aa65bcf5e… | 309 ms (account API) | matches the list, identical to the original | 2026-11-04 |
| a person, Launch interactive | pasted into the address bar | 65536 (64 KB), sha256 959b88aba150… | 309 ms (account API) | matches the list, identical to the original | 2026-11-04 |
| an AI agent, hosted MCP | navigate to the file link | 102400 (100 KB), sha256 69c384ebb4f1… | 741 ms (downloads tool) | matches the list, identical to the original | 2026-11-04 |
| an AI agent, hosted MCP | click on a link | 1024 (1 KB), sha256 3f15a7590652… | 741 ms (downloads tool) | matches the list (random bytes: no original to compare) | 2026-11-04 |
From the Node.js run on 2026-11-04. 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)
{
"language": "node",
"person": {
"launch_downloads": true,
"resource_class": "linux_headless",
"live_protocol": "screencast",
"start_page_shown": true,
"frames": 7,
"pasted": [
"https://httpbingo.org/bytes/204800?seed=82",
"https://httpbingo.org/bytes/65536?seed=7"
],
"page_after": "https://httpbingo.org/",
"end_status": 200,
"end_ms": 75,
"listed_after_ms": 309,
"files": [
{
"name": "204800",
"bytes": 204800,
"sha256": "4b2aa65bcf5e8352567b9360449ae160428bb23569ca520cead5909f13e96987",
"source_url": "https://httpbingo.org/bytes/204800?seed=82",
"link": "GET /v1/me/downloads/{id}/url (900 s)",
"fetched_status": 200,
"fetched_type": "application/octet-stream",
"fetched_bytes": 204800,
"fetched_sha256": "4b2aa65bcf5e8352567b9360449ae160428bb23569ca520cead5909f13e96987",
"original_bytes": 204800,
"original_sha256": "4b2aa65bcf5e8352567b9360449ae160428bb23569ca520cead5909f13e96987"
},
{
"name": "65536",
"bytes": 65536,
"sha256": "959b88aba150008c47bbc36cd2f68d8debd29e6849520b2301d1ecbbd02e2898",
"source_url": "https://httpbingo.org/bytes/65536?seed=7",
"link": "GET /v1/me/downloads/{id}/url (900 s)",
"fetched_status": 200,
"fetched_type": "application/octet-stream",
"fetched_bytes": 65536,
"fetched_sha256": "959b88aba150008c47bbc36cd2f68d8debd29e6849520b2301d1ecbbd02e2898",
"original_bytes": 65536,
"original_sha256": "959b88aba150008c47bbc36cd2f68d8debd29e6849520b2301d1ecbbd02e2898"
}
]
},
"agent": {
"session_id_found": true,
"calls": [
{
"tool": "launch_browser",
"ms": 602,
"error": false,
"text": "Launched chromium 148.0.7778.96 (linux_headless, 1 thread(s)), session e5cd63b0-e45e-42cd-9dd6-b6cfd8d6bea1. Proxy: your proxy. Use navigate next."
},
{
"tool": "navigate",
"ms": 1264,
"error": false,
"text": "200 https://httpbingo.org/"
},
{
"tool": "click",
"ms": 552,
"error": false,
"text": "Clicked. Now at https://httpbingo.org/"
},
{
"tool": "navigate",
"ms": 431,
"error": true,
"text": "page.goto: Download is starting"
},
{
"tool": "evaluate",
"ms": 42,
"error": false,
"text": "https://httpbingo.org/"
},
{
"tool": "close_browser",
"ms": 45,
"error": false,
"text": "Session e5cd63b0-e45e-42cd-9dd6-b6cfd8d6bea1 ended."
},
{
"tool": "downloads",
"ms": 137,
"error": false,
"text": "102400 — 102400 bytes, sha256 69c384ebb4f18694d18e6c5bb03bac3a261bd1be2227ade66b842d3cee765c3f",
"polls": 2
}
],
"tool_listed_after_ms": 741,
"first_downloads_call": "No kept files for session e5cd63b0-e45e-42cd-9dd6-b6cfd8d6bea1 yet. They appear within about a minute after the session ends, and only for sessions launched wit",
"tool_files": [
{
"name": "102400",
"bytes": 102400,
"sha256": "69c384ebb4f18694d18e6c5bb03bac3a261bd1be2227ade66b842d3cee765c3f",
"link_host": "cdpfleet.com"
},
{
"name": "1024",
"bytes": 1024,
"sha256": "3f15a75906526a96a1cd8bf481d5294a1f36e6ca97747b81a8cd8a6fb9a50c81",
"link_host": "cdpfleet.com"
}
],
"files": [
{
"name": "102400",
"bytes": 102400,
"sha256": "69c384ebb4f18694d18e6c5bb03bac3a261bd1be2227ade66b842d3cee765c3f",
"source_url": "https://httpbingo.org/bytes/102400?seed=79",
"link": "from the downloads tool",
"fetched_status": 200,
"fetched_type": "application/octet-stream",
"fetched_bytes": 102400,
"fetched_sha256": "69c384ebb4f18694d18e6c5bb03bac3a261bd1be2227ade66b842d3cee765c3f",
"original_bytes": 102400,
"original_sha256": "69c384ebb4f18694d18e6c5bb03bac3a261bd1be2227ade66b842d3cee765c3f"
},
{
"name": "1024",
"bytes": 1024,
"sha256": "3f15a75906526a96a1cd8bf481d5294a1f36e6ca97747b81a8cd8a6fb9a50c81",
"source_url": "https://httpbingo.org/bytes/1024",
"link": "from the downloads tool",
"fetched_status": 200,
"fetched_type": "application/octet-stream",
"fetched_bytes": 1024,
"fetched_sha256": "3f15a75906526a96a1cd8bf481d5294a1f36e6ca97747b81a8cd8a6fb9a50c81",
"original_bytes": null,
"original_sha256": null
}
]
},
"measured_at": "2026-11-04"
}Takeaways
- The files are there within about a second of the session ending. On 2026-11-04, across five runs (one per language), the person's two files were listed by
GET /v1/me/downloads0.3–0.9 s afterDELETE /v1/me/threads/{id}returned, and the agent's two files were in thedownloadstool's answer 0.7–1.2 s afterclose_browserreturned. The docs promise "within about a minute"; a short poll is enough. - Ask the
downloadstool again. Called straight afterclose_browser, the tool answered "No kept files for session … yet" in all five runs; the next call half a second later (the third, in one run) listed both files with a private link each. The answer says the files appear within about a minute — an agent that gives up after the first reply misses them. - The kept copies are the originals. All 20 files (four per run) fetched through their private links had exactly the listed size and sha256, served as
application/octet-stream. The three seeded files — 200 KB and 64 KB from the person, 100 KB from the agent — were byte-identical to the same URLs downloaded directly, in every run. Check thesha256the list gives you; it is the file's. - Nobody's code has to be there. The Launch interactive session never had a client: the person pasted two file links into live view's address bar, the tab stayed on httpbingo.org, and both files were kept. The dashboard's Launch interactive uses the code generator's settings, where
downloadsis a toggle. - For an agent, "Download is starting" isn't a failure.
navigateto a file link came back as an error (page.goto: Download is starting) in every run, yet the file was downloaded and kept like the one the agent clicked. Tell your agent that, or have itclickthe link instead.