File uploads and downloads
The shared /workspace disk: browser downloads, files you upload for the browser, reading, writing and downloading files and whole folders, also after the session has stopped.
Every session has a disk at /workspace (the session’s workspacePath): 10 GiB with a shell, 5 GiB for a browser-only session. When it is full, writes fail with “No space left on device”; delete files to make room. The browser, the shell and the API all see the same files:
- browser downloads land in
/workspace/downloads, whichever client triggers them; - files you upload through the API are there for the browser to use in a file input;
- the shell starts in
/workspaceand can read and write it; - the files API lists, reads, writes and deletes files in it, and downloads a whole folder as one archive.
That is what lets an agent download a CSV in the browser, analyse it with Python and hand the result back, without the file ever leaving the machine. The workspace is saved when the session stops, and comes back when you resume it.
Files work on every kind of session: a browser only, a shell only, or both. See the three kinds of session. Reading, listing and downloading files also work after the session has stopped (see below).
Paths in the files API are relative to /workspace (downloads/report.csv), or absolute paths inside it (/workspace/downloads/report.csv). Paths outside the workspace are rejected with 403 invalid_path.
Downloads
Whatever triggers a download (Playwright, Puppeteer, the actions API, the AI agent or a click in the live view), the file lands in /workspace/downloads. Chrome’s unfinished .crdownload files are ignored until the download is done. Wait for the file, then read it, process it in the shell, or copy it to your own machine. A shell-only session has no browser, so nothing lands there by itself: fetch files in the shell with curl or wget instead.
# Answers once a finished file matches, or 408 after timeoutMs.curl -G https://api.boxline.dev/v1/sessions/$SESSION_ID/files/wait \ -H "x-api-key: $BOXLINE_API_KEY" \ --data-urlencode "pattern=downloads/*.csv" \ --data-urlencode "timeoutMs=30000"curl -G https://api.boxline.dev/v1/sessions/$SESSION_ID/files \ -H "x-api-key: $BOXLINE_API_KEY" \ --data-urlencode "path=downloads/report.csv" \ -o report.csv// The browser clicked "Export"; wait for the download.const csv = await session.files.waitFor("downloads/*.csv");// Process it in the shell, on the same machine.await session.exec(`python3 summarise.py ${csv.path} > summary.csv`);// Bring the result home.const summary = await session.files.readText("summary.csv");# The browser clicked "Export"; wait for the download.csv = session.files.wait_for("downloads/*.csv")# Process it in the shell, on the same machine.session.exec(f"python3 summarise.py {csv['path']} > summary.csv")# Bring the result home.summary = session.files.read_text("summary.csv")To reuse the browser’s login from the shell, export its cookies.
Upload a file for the browser
To give a website a file, for example in an upload form, put the file in the workspace first, then set the form’s file input to it with the upload action (it needs a session with a browser).
PUT /v1/sessions/:id/files?path=with the file’s bytes as the body. Folders are created for you, and the file appears only once it is fully written.{ "action": "upload", "selector": "input[type=file]", "path": "/workspace/report.pdf" }sets the file input. Thepathis inside the session.
curl -X PUT "https://api.boxline.dev/v1/sessions/$SESSION_ID/files?path=report.pdf" \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/octet-stream" \ --data-binary @report.pdfcurl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/browser/actions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"actions": [ {"action": "upload", "selector": "input[type=file]", "path": "/workspace/report.pdf"}, {"action": "click", "selector": "button[type=submit]"} ]}'import { readFile } from "node:fs/promises";await session.files.write("report.pdf", await readFile("report.pdf"));await session.actions([ { action: "upload", selector: "input[type=file]", path: "/workspace/report.pdf" }, { action: "click", selector: "button[type=submit]" },]);with open("report.pdf", "rb") as f: session.files.write("report.pdf", f.read())session.actions([ {"action": "upload", "selector": "input[type=file]", "path": "/workspace/report.pdf"}, {"action": "click", "selector": "button[type=submit]"},])With Playwright, page.setInputFiles() sends a file from your own computer instead, which works too.
Coming soon: a session.files.upload() helper in the SDKs, plain-English steps that understand “upload report.pdf”, and a size limit per upload that depends on your plan.
Read a file
GET /v1/sessions/:id/files?path= returns the file’s bytes. It always comes as a download (application/octet-stream, as an attachment), never shown as a web page, because the files come from a machine that runs untrusted code. It also works after the session has stopped: see Files of a stopped session.
curl -G https://api.boxline.dev/v1/sessions/$SESSION_ID/files \ -H "x-api-key: $BOXLINE_API_KEY" \ --data-urlencode "path=summary.csv" \ -o summary.csvconst bytes = await session.files.read("summary.csv"); // a Uint8Arrayconst text = await session.files.readText("summary.csv");data = session.files.read("summary.csv") # bytestext = session.files.read_text("summary.csv")files_read{"sessionId": "SESSION_ID", "path": "summary.csv"}boxline files get $SESSION_ID summary.csv # saves summary.csv hereboxline files get $SESSION_ID summary.csv - # prints itList a folder
Add list=1, or point path at a folder. It works on a stopped session too.
curl -G https://api.boxline.dev/v1/sessions/$SESSION_ID/files \ -H "x-api-key: $BOXLINE_API_KEY" \ --data-urlencode "path=downloads" \ --data-urlencode "list=1"const { entries } = await session.files.list("downloads");for (const e of entries) console.log(e.type, e.size, e.name);for e in session.files.list("downloads"): print(e["type"], e["size"], e["name"])files_list{"sessionId": "SESSION_ID", "path": "downloads"}boxline files ls $SESSION_ID downloads{ "path": "/workspace/downloads", "entries": [ { "name": "report.csv", "type": "file", "size": 48213, "mtime": "2026-09-28T12:01:07.000Z" } ]}type is "file", "dir" or "other". On a stopped session a link is listed as "symlink".
Write a file
PUT /v1/sessions/:id/files?path=. The request body is the raw file contents. The response is { path, size }. The session must be running.
curl -X PUT "https://api.boxline.dev/v1/sessions/$SESSION_ID/files?path=summarise.py" \ -H "x-api-key: $BOXLINE_API_KEY" \ --data-binary @summarise.pyDelete a file
DELETE /v1/sessions/:id/files?path= deletes a file or a folder, and returns 204. The session must be running.
curl -X DELETE "https://api.boxline.dev/v1/sessions/$SESSION_ID/files?path=summary.csv" \ -H "x-api-key: $BOXLINE_API_KEY"Wait for a file
GET /v1/sessions/:id/files/wait?pattern=&timeoutMs=
Downloads finish in their own time. Instead of polling, ask the session to answer once a file matching a glob exists and has stopped growing. The glob applies to the last part of the path, relative to /workspace, for example downloads/*.csv. The response is { path, size } for the newest match, with an absolute path such as /workspace/downloads/report.csv. If nothing matches in time (default 30 seconds, at most 10 minutes), the request fails with 408. The session must be running.
Download a folder
GET /v1/sessions/:id/files/archive?path= returns a folder and everything in it as one .tar.gz: everything an agent made, in one call. path is the folder, and the whole workspace when you leave it out. Like every file, it comes as a download (application/octet-stream, as an attachment). It works on a running session and on a stopped one.
curl -G https://api.boxline.dev/v1/sessions/$SESSION_ID/files/archive \ -H "x-api-key: $BOXLINE_API_KEY" \ --data-urlencode "path=output" \ -o output.tar.gztar -xzf output.tar.gzimport { writeFile } from "node:fs/promises";const archive = await session.files.archive("output"); // a Uint8Array: the .tar.gzawait writeFile("output.tar.gz", archive);archive = session.files.archive("output") # bytes: the .tar.gzwith open("output.tar.gz", "wb") as f: f.write(archive)boxline files get $SESSION_ID output # a folder is saved as a .tar.gz.boxline/at the top of the folder is left out. A link is stored as a link and never followed.- At most 1 GiB and 100,000 entries. A bigger folder is refused before any byte is sent.
- Only two archives of one session can be made at a time.
| Status | Code | When |
|---|---|---|
| 400 | not_a_directory | path is a file. Read it with Read a file. |
| 403 or 400 | invalid_path | The path is outside the workspace, or goes through a link. |
| 404 | not_found | There is no such folder. |
| 413 | archive_too_large | Over 1 GiB or 100,000 entries. Download smaller folders, or single files. |
| 429 | archive_busy | Two archives of this session are already being made. Try again when one is done. |
| 409 | nothing_saved | A stopped session that saved no workspace (see below). |
| 409 | machine_too_old | The session runs on a machine from before a platform update. Start a new session. |
Every field is in the API reference: Download a folder.
Files of a stopped session
A stopped session keeps its workspace until it is deleted (see How long they are kept). Three calls read it without resuming the session and without starting a machine, so no machine time is billed:
- reading a file:
GET /v1/sessions/:id/files?path=; - listing a folder: the same call with
list=1; - downloading a folder:
GET /v1/sessions/:id/files/archive?path=.
curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/stop -H "x-api-key: $BOXLINE_API_KEY"# No machine runs now, and the files are still there.curl -G https://api.boxline.dev/v1/sessions/$SESSION_ID/files \ -H "x-api-key: $BOXLINE_API_KEY" \ --data-urlencode "path=output/total.txt"await session.stop();const total = await session.files.readText("output/total.txt"); // read from the saved workspacesession.stop()total = session.files.read_text("output/total.txt") # read from the saved workspaceboxline sessions stop $SESSION_IDboxline files get $SESSION_ID output/total.txt -They answer from the workspace the session saved: the copy made when it stopped, or, after a lost machine, its last checkpoint (checkpointAt on the session), which is the same copy a resume restores.
| Call | On a stopped session |
|---|---|
| Read a file, list a folder, download a folder | Works, read-only, from the saved workspace. |
| Upload a file, delete a file, wait for a file | 409 session_not_running: resume the session first. |
- If the session saved no workspace (a machine lost before its first checkpoint, or a session that stopped without saving), the read answers
409 nothing_saved. A resume answers the same. - A deleted session’s files are gone:
409 session_not_running. .boxline/is left out. A link is listed as a link and never followed, and an entry that is not a regular file cannot be downloaded.- One request scans at most 1 GiB of the saved workspace, uncompressed (
413 payload_too_largebeyond that). For a file deep in a very large workspace, download its folder with the archive call. - These reads count against 60 a minute per project.
From the command line
boxline files ls $SESSION_ID downloadsboxline files get $SESSION_ID downloads/report.csv # saves report.csv here ("-" prints it)boxline files get $SESSION_ID output # a folder is saved as a .tar.gzboxline files put $SESSION_ID data/input.csv ./input.csvboxline files rm $SESSION_ID data/input.csvSee the CLI.
With the SDKs
| Node | Python | Returns |
|---|---|---|
session.files.read(path) | session.files.read(path) | The bytes |
session.files.readText(path) | session.files.read_text(path) | The text |
session.files.archive(path) | session.files.archive(path) | The folder as one .tar.gz (the bytes) |
session.files.write(path, data) | session.files.write(path, data) | { path, size } |
session.files.list(path = ".") | session.files.list(path=".") | Node: { path, entries }; Python: the entries |
session.files.delete(path) | session.files.delete(path) | Nothing |
session.files.waitFor(pattern, timeoutMs = 30000) | session.files.wait_for(pattern, timeout_ms=30000) | { path, size } |
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.