> Full docs index: https://docs.steel.dev/llms.txt

# Move files between your machine and a cloud browser
URL: https://docs.steel.dev/cookbook/files


**TypeScript**

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](/cookbook/topics/steel-apis).

```typescript
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:

```typescript
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

```bash
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](https://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:

```text
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.

## Related

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

**Python**

`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)`.

```python
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:

```python
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

```bash
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](https://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:

```text
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.

## Related

[TypeScript version](/cookbook/files) covers the same flow with the Web `File` API. [Go version](/cookbook/files) and [Rust version](/cookbook/files) build the upload from typed structs. The CDP calls map to Playwright's [`new_cdp_session`](https://playwright.dev/python/docs/api/class-cdpsession); the protocol methods are in the [DOM domain reference](https://chromedevtools.github.io/devtools-protocol/tot/DOM/).

**Rust**

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.

```rust
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:

```rust
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

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

Get a key at [app.steel.dev/settings/api-keys](https://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:

```text
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.

## Related

[files-ts](/cookbook/files), [files-py](/cookbook/files), and [files-go](/cookbook/files) are the same recipe in other languages. The [chromiumoxide docs](https://docs.rs/chromiumoxide) cover `page.execute` and the generated CDP command types under `chromiumoxide::cdp::browser_protocol`.

**Go**

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:

```go
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:

```go
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

```bash
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](https://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:

```text
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/`.

## Related

[files-ts](/cookbook/files) and [files-py](/cookbook/files) and [files-rs](/cookbook/files) for the same recipe in other languages. [chromedp](https://github.com/chromedp/chromedp) and its [cdproto/dom](https://pkg.go.dev/github.com/chromedp/cdproto/dom) package for the raw CDP surface used here.

## Related recipes

- [Upload and run browser extensions](/cookbook/extensions): Use the Steel Extensions API with Playwright to upload and run browser extensions.
- [Scrape a page to Markdown, screenshot, and PDF](/cookbook/scrape): Use the Steel TypeScript SDK's direct API to scrape a page to clean Markdown for LLM context, plus screenshot and PDF, with no browser library.
- [Watch Claude pricing for divergent A/B variants](/cookbook/convex-price-watch): Convex cron plus two parallel Steel proxy probes against claude.com/pricing. Stores per-tier per-region snapshots and surfaces tiers where the probes disagree.
