Agent Ops Notes

Record — file transfer in agent-driven browsers

File upload and blob download in agent-driven browsers: three patterns that work

Last tested
2026-09-10 and 2026-09-11
Environment
ZCode desktop agent, in-app browser (IAB), Chromium kernel, macOS (Apple Silicon)
Applies to
Agentic browser automation where the Playwright-like surface has no native file-chooser support. Exact behavior may differ in other agent browsers or future versions.

Problem

The automation surface of the in-app browser exposes clicks, typing, and DOM evaluation, but a native file chooser is explicitly unsupported — a normal setFiles-style upload fails. Separately, when a web app generates a download in page JavaScript (a Blob handed to URL.createObjectURL and clicked through an anchor), the bytes never reach the agent's filesystem, so the agent cannot verify what the page actually produced. A third annoyance: each agent JavaScript call runs in a fresh kernel, so a variable captured in one call is gone in the next.

Pattern 1 — upload: inject a File through DataTransfer

Construct the file in page context, assign it to the input's files list, and dispatch change. This works for most JS-driven upload widgets (validated, drop-zone apps, converters) because they all read input.files.

// inside page evaluate(); b64 = base64 of the local file, read by the agent beforehand
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const file = new File([bytes], "sample.mp4", { type: "video/mp4" });
const dt = new DataTransfer();
dt.items.add(file);
const input = document.querySelector('input[type="file"]'); // or getElementById(...)
input.files = dt.files;
input.dispatchEvent(new Event("change", { bubbles: true }));

Verified against four different sites during a product-research session (validators, converters); each treated the injected file exactly like a user pick.

Pattern 2 — download: intercept URL.createObjectURL

Patch URL.createObjectURL before triggering the download button, read the Blob back as base64, and write it to disk from the agent's shell in the same call.

const result = await page.evaluate(() => new Promise(resolve => {
  const orig = URL.createObjectURL.bind(URL);
  URL.createObjectURL = blob => {
    const url = orig(blob);
    if (blob && blob.type && blob.type.includes("zip")) {
      const fr = new FileReader();
      fr.onload = () => resolve({ b64: String(fr.result).split(",")[1] });
      fr.readAsDataURL(blob);
    }
    return url;
  };
  document.querySelector("#generate").click();
  setTimeout(() => resolve({ timeout: true }), 15000);
}));
// same call: fs.writeFileSync("out.zip", Buffer.from(result.b64, "base64"))

Verified by capturing generated ZIPs from a local tool and from a third-party converter, then hash-comparing the captured bytes.

Pattern 3 — state: the kernel does not persist between calls

Each automation call starts a fresh JavaScript kernel. A captured blob or a patched function is gone in the next call. Anything you need later must be written to disk inside the same call, or re-created in the next call. Treat every agent browser call as a separate process.

What this adds beyond the docs

The official documentation for this browser states file-chooser automation is unsupported — and stops there. What was missing is the working set of workarounds with their exact boundaries: which upload widgets accept injection, how to verify generated downloads byte-for-byte, and the cross-call state rule that bites you the first time a capture "mysteriously" disappears.

Limitations