Move files between your machine and a cloud browser

Use the Steel Files API with Playwright to automate file uploads and downloads in the cloud.

examples/files-ts
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

Every Steel session ships with a scoped filesystem inside the session VM. client.sessions.files exposes methods to move bytes across the boundary between your machine and that sandbox. This recipe uses upload to push a local CSV into the session, hands the resulting path to a remote <input type="file"> over CDP, and lets the browser render a chart against it. It's one of Steel's session APIs.

const uploadedFile = await client.sessions.files.upload(session.id, {
file,
});

file is a Web File built from fs.readFileSync("./assets/stock.csv"). What comes back is a record whose path is a handle inside the session VM (something like stock.csv at the sandbox root). That path is not valid on your laptop, and paths on your laptop are not valid inside the session. The whole recipe hinges on keeping that distinction straight.

Wiring a remote file into a DOM input

page.setInputFiles("./local.csv") resolves paths on the machine running Playwright. Since Chromium lives on a Steel VM, you need to resolve the path there instead. The main function drops down to raw CDP:

const cdpSession = await currentContext.newCDPSession(page);
const document = await cdpSession.send("DOM.getDocument");
const inputNode = await cdpSession.send("DOM.querySelector", {
nodeId: document.root.nodeId,
selector: "#load-file",
});
await cdpSession.send("DOM.setFileInputFiles", {
files: [uploadedFile.path],
nodeId: inputNode.nodeId,
});

DOM.setFileInputFiles runs browser-side, so uploadedFile.path resolves against the session VM, which is exactly where upload() wrote the bytes. After that, it's plain Playwright: wait for svg.main-svg, scroll into view, screenshot to stock.png on your local disk.

Run it

cd examples/files-ts
cp .env.example .env # set STEEL_API_KEY
npm install
npm start

Get a key at app.steel.dev/settings/api-keys. The script prints a session viewer URL as it starts. Open it in another tab to watch the upload land and the chart render.

Your output varies. Structure looks like this:

Steel + Files API Starter
============================================================
Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...
Uploading CSV file to the Steel session...
CSV file uploaded successfully!
File path on Steel session: stock.csv
Connected to browser via Playwright
Releasing session...
Session released
Done!

stock.png lands in the recipe folder. It's the rendered chart, captured server-side after the CSV was parsed remotely, then saved locally. A run takes ~15 seconds.

The rest of the surface

The recipe touches upload and nothing else, but client.sessions.files has more:

  • list(sessionId): returns every file in the session namespace with { path, size, lastModified }. Useful after the browser triggers a download and you need to find the new file.
  • download(sessionId, path): pulls a single file back out. Stream the response body to disk.
  • downloadArchive(sessionId): zips the whole namespace into one response. One call instead of N.
  • delete(sessionId, path) and deleteAll(sessionId): explicit cleanup. Releasing the session also clears storage.

Browser-initiated downloads (PDF exports, file-save dialogs) land in the same namespace automatically, so the inverse of this recipe is: drive the page to export, then list() and download() what showed up.

There's also client.files (without .sessions), an organization-scoped store that persists across sessions. Same method shape. Useful for fixtures and assets you don't want to re-upload every run.

Make it yours

  • Upload from a URL. Pass a string instead of a File: client.sessions.files.upload(session.id, { file: "https://example.com/report.pdf" }). Steel fetches it server-side and drops it in the session namespace, skipping your machine entirely.
  • Harvest generated files. Swap the csvplot.com flow for a site that exports. After the download fires, call list() to discover the new path, then download() it back.
  • Target a nested path. upload() accepts a path argument to control where the file lands inside the sandbox. Default is the filename at root; pass path: "inputs/stock.csv" to nest.

Credentials for auth tokens kept out of the filesystem. Auth context for cookies and storage state. Profiles for persistent user-data directories across runs. Extensions for loading unpacked Chrome extensions into a session.

examples/files-py
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

client.sessions.files moves bytes between your machine and the filesystem that lives inside a Steel session VM. This recipe uploads a local CSV into the session, hands the path the upload returns to a remote <input type="file"> over raw CDP, and screenshots the chart the page renders from it. The whole thing turns on one fact: a file you push over the API lands at a path the browser can read, and that path means nothing back on your laptop.

Shaping the upload

The Python SDK speaks multipart/form-data, so the file argument takes the same tuple shape as requests or the OpenAI client: (filename, content, content_type).

csv_bytes = (Path(__file__).parent / "assets" / "stock.csv").read_bytes()
uploaded = client.sessions.files.upload(
session.id,
file=("stock.csv", csv_bytes, "text/csv"),
)

uploaded.path comes back as a handle inside the session sandbox (typically just stock.csv at the root). Pass a URL string instead of the tuple and Steel fetches the file server-side, so the bytes never touch your machine at all.

Reaching the input over CDP

page.set_input_files("./stock.csv") resolves paths on the host running Playwright. The browser is on a Steel VM, so the file has to be resolved there. That means dropping under Playwright's locators to the Chrome DevTools Protocol, which new_cdp_session exposes as a send(method, params) call:

cdp = current_context.new_cdp_session(page)
document = cdp.send("DOM.getDocument")
input_node = cdp.send(
"DOM.querySelector",
{"nodeId": document["root"]["nodeId"], "selector": "#load-file"},
)
cdp.send(
"DOM.setFileInputFiles",
{"files": [uploaded.path], "nodeId": input_node["nodeId"]},
)

send returns plain dicts, so the node ids are read with subscript access. Because DOM.setFileInputFiles runs browser-side, uploaded.path resolves against the VM, exactly where upload wrote it. After that it is ordinary Playwright: wait for svg.main-svg, scroll it into view, and screenshot it to stock.png on your local disk.

Run it

cd examples/files-py
cp .env.example .env # set STEEL_API_KEY
uv run main.py

Grab a key at app.steel.dev/settings/api-keys. uv sync runs automatically on first uv run, so there is no separate install step. The script prints a session viewer URL as it starts. Open it in another tab to watch the upload land and the chart render.

Your output varies. Structure looks like this:

Steel + Files API Starter
============================================================
Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...
Uploading CSV file to the Steel session...
CSV file uploaded successfully!
File path on Steel session: stock.csv
Connected to browser via Playwright
Releasing session...
Session released
Done!

stock.png lands in the recipe folder: the chart, parsed and drawn remotely, captured server-side, then saved to your disk. A run takes about 15 seconds.

Make it yours

  • Skip your machine. Pass a URL string for file instead of the tuple, and Steel downloads it into the session directly.
  • Nest the upload. upload takes a path argument that sets where the file lands in the sandbox. Default is the filename at root; pass path="inputs/stock.csv" to nest it.
  • Pull files back out. client.sessions.files.list(session.id) enumerates the namespace, and download(session.id, path) returns the bytes. Browser-initiated downloads land in the same namespace, so the inverse recipe is: drive the page to export, then list and download what appeared.

TypeScript version covers the same flow with the Web File API. Go version and Rust version build the upload from typed structs. The CDP calls map to Playwright's new_cdp_session; the protocol methods are in the DOM domain reference.

examples/files-rs
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

Each Steel session owns a scoped filesystem inside its VM, and client.sessions().files() moves bytes across the boundary between your machine and that sandbox. This recipe reads a local CSV, uploads it with upload, captures the path the file landed at inside the session, and hands that path to a remote <input type="file"> so csvplot.com renders a chart against bytes that never touched the browser's own disk.

let uploaded = client
.sessions()
.files()
.upload(
session_id,
SessionFileUploadParams {
file: FileUpload::new("stock.csv", bytes).with_content_type("text/csv"),
path: None,
},
)
.await?;

FileUpload::new takes a filename and the raw bytes; with_content_type is the builder step for the MIME type. What comes back is a File whose path is a handle inside the session VM (for this asset, stock.csv at the sandbox root). That path is meaningful to the browser running on Steel, not to your laptop, and keeping those two namespaces straight is the whole point of the recipe.

Driving a file input over raw CDP

chromiumoxide's typed helpers resolve file paths on the machine running your code, which is the wrong filesystem here. The fix is to issue the DOM commands yourself. chromiumoxide re-exports the generated CDP types under chromiumoxide::cdp::browser_protocol, and page.execute(...) sends any of them and deserializes the typed reply:

let document = page.execute(GetDocumentParams::default()).await?;
let input = page
.execute(QuerySelectorParams::new(document.root.node_id, "#load-file"))
.await?;
page.execute(SetFileInputFilesParams {
files: vec![uploaded.path.clone()],
node_id: Some(input.node_id),
backend_node_id: None,
object_id: None,
})
.await?;

DOM.setFileInputFiles runs browser-side, so uploaded.path resolves against the session VM, which is exactly where upload wrote the bytes. After that it is ordinary automation: poll for svg.main-svg, scroll it into view, and screenshot the element to stock.png on your local disk.

Run it

cd examples/files-rs
cp .env.example .env # set STEEL_API_KEY
cargo run

Get a key at app.steel.dev/settings/api-keys. The program prints a session viewer URL as it starts. Open it in another tab to watch the upload land and the chart render.

Your output varies. Structure looks like this:

Creating Steel session...
Session live at https://app.steel.dev/sessions/ab12cd34...
Uploading stock.csv (5488 bytes) to the session...
Uploaded. Path inside the session VM: stock.csv
Connected over CDP, opening csvplot.com...
Setting the uploaded file on the page's #load-file input...
Saved stock.png (48213 bytes)
Releasing session...
Session released

stock.png lands in the recipe folder: the rendered chart, captured server-side after the CSV was parsed remotely, then saved locally.

Make it yours

  • Upload from a URL. FileUpload carries the bytes here, but the underlying endpoint also accepts a URL it fetches server-side, so you can skip reading the file locally for large fixtures.
  • Harvest generated files. Swap the csvplot flow for a site that exports. After the download fires, call files().list(session_id) to discover the new path, then files().download(session_id, &path) to pull the bytes back.
  • Target a nested path. Set path: Some("inputs/stock.csv".into()) on SessionFileUploadParams to control where the file lands inside the sandbox instead of the default filename at root.

files-ts, files-py, and files-go are the same recipe in other languages. The chromiumoxide docs cover page.execute and the generated CDP command types under chromiumoxide::cdp::browser_protocol.

examples/files-go
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

A Steel session carries its own filesystem inside the session VM. client.Sessions.Files moves bytes across the boundary between your machine and that sandbox. This recipe reads a local CSV, uploads it with Upload, then hands the returned server-side path to a remote <input type="file"> so csvplot.com can render a chart against bytes that never lived on the browser host's local disk.

The upload is a plain Go value, not an io.Reader or a multipart form you assemble yourself:

uploaded, err := client.Sessions.Files.Upload(ctx, sess.ID, steel.SessionFileUploadParams{
File: steel.FileUpload{
Name: "stock.csv",
Content: csvBytes,
ContentType: "text/csv",
},
})

Content is the raw []byte you got from os.ReadFile. What comes back is a *steel.File whose Path is a handle inside the session VM (typically stock.csv at the sandbox root). That path is meaningless on your laptop, and your laptop's paths are meaningless inside the session. Keeping that distinction straight is the whole point.

Wiring a remote file into a DOM input

chromedp's chromedp.SetUploadFiles resolves paths on the machine running chromedp, which is your laptop. The file we want lives on the Steel VM, so we drop to raw CDP from github.com/chromedp/cdproto/dom instead. DOM.setFileInputFiles runs browser-side, so uploaded.Path resolves against the session VM, exactly where Upload wrote the bytes. setRemoteFileInput wraps the three CDP calls in a chromedp.ActionFunc so it slots into a normal chromedp.Run task list:

func setRemoteFileInput(selector, remotePath string) chromedp.Action {
return chromedp.ActionFunc(func(ctx context.Context) error {
root, err := dom.GetDocument().Do(ctx)
if err != nil {
return err
}
nodeID, err := dom.QuerySelector(root.NodeID, selector).Do(ctx)
if err != nil {
return err
}
return dom.SetFileInputFiles([]string{remotePath}).WithNodeID(nodeID).Do(ctx)
})
}

After that it is ordinary chromedp: WaitVisible("svg.main-svg"), then FullScreenshot to stock.png on your local disk.

Run it

cd examples/files-go
cp .env.example .env # set STEEL_API_KEY
go mod tidy
go run .

Get a key at app.steel.dev/settings/api-keys. The program prints a session viewer URL as it starts. Open it in another tab to watch the upload land and the chart render.

Your output varies. Structure looks like this:

Creating Steel session...
Session created. Watch it live at https://app.steel.dev/sessions/ab12cd34...
Uploading stock.csv to the session...
Uploaded. Path inside the session VM: stock.csv
Loading csvplot.com and feeding it the uploaded file...
Saved chart to stock.png
Releasing session...

stock.png lands in the recipe folder. It is the rendered chart, captured server-side after the CSV was parsed remotely, then saved locally.

Make it yours

  • Upload from a URL. steel.FileUpload carries bytes, but the underlying API also accepts a URL string for the file field. Fetch a report server-side and skip your machine entirely.
  • Harvest generated files. Swap the csvplot.com flow for a site that exports. After the download fires, call client.Sessions.Files.List(ctx, sess.ID) to discover the new path, then client.Sessions.Files.Download(ctx, sess.ID, path) to pull it back as an io.ReadCloser.
  • Target a nested path. SessionFileUploadParams has an optional Path field. The default is the filename at the sandbox root; set Path to a pointer to nest the upload, for example under inputs/.

files-ts and files-py and files-rs for the same recipe in other languages. chromedp and its cdproto/dom package for the raw CDP surface used here.