cdpfleet Docs Sign up free

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.

Chromium

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

WhatWhy
"downloads": trueKeeps every file the session downloads, for 2 days (paid plans). In MCP it is the downloads argument of launch_browser.
start_url + live_viewLaunch 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 messageWhat 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/mcpThe 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}/urlThe 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

  1. Launch headless Chromium with downloads, live_view and start_url: https://httpbingo.org/, and no client. Get a control live link and open it.
  2. 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}.
  3. Poll GET /v1/me/downloads?session_id=… until both files are listed, fetch each through GET /v1/me/downloads/{id}/url, and compare size and sha256 with the list and with the original.
  4. Over MCP: launch Chromium with downloads: true, open httpbingo.org, click its /bytes/:n example link, navigate to a 100 KB file link, read where the page is, and close_browser two seconds later.
  5. Call the downloads tool 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:

// 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));

What we got

Who downloadedHowFileListed after the session endedFetched copyMeasured
a person, Launch interactivepasted into the address bar204800 (200 KB), sha256 4b2aa65bcf5e…309 ms (account API)matches the list, identical to the original2026-11-04
a person, Launch interactivepasted into the address bar65536 (64 KB), sha256 959b88aba150…309 ms (account API)matches the list, identical to the original2026-11-04
an AI agent, hosted MCPnavigate to the file link102400 (100 KB), sha256 69c384ebb4f1…741 ms (downloads tool)matches the list, identical to the original2026-11-04
an AI agent, hosted MCPclick on a link1024 (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