> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tilion.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Upload and download files

> Send a file to a page's file input and collect what the browser downloads.

Start with a [running session](https://docs.tilion.dev/guides/create-browser.md) and set `TILION_API_KEY` and `TILION_SESSION_ID`. These routes work while a Playwright or CDP client is connected.

## Upload a file

Set `UPLOAD_FILE` to a local path. Each example streams the file and prints the path on the session's machine. The default limit is 200 MB.

```python Python theme={"dark"}
import os
from urllib.parse import quote
import requests

sid = os.environ["TILION_SESSION_ID"]
local = os.environ["UPLOAD_FILE"]
name = os.path.basename(local)

with open(local, "rb") as body:
    response = requests.put(
        f"https://back.tilion.dev/v1/session/{sid}/files/{quote(name, safe='')}",
        headers={"Authorization": f"Bearer {os.environ['TILION_API_KEY']}"},
        data=body, timeout=600,
    )
response.raise_for_status()
print(response.json()["path"])
```

```javascript Node.js theme={"dark"}
import { createReadStream, statSync } from "node:fs";
import { basename } from "node:path";
import { Readable } from "node:stream";

const local = process.env.UPLOAD_FILE;
const response = await fetch(
  `https://back.tilion.dev/v1/session/${process.env.TILION_SESSION_ID}/files/${encodeURIComponent(basename(local))}`,
  {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${process.env.TILION_API_KEY}`,
      "Content-Length": String(statSync(local).size),
    },
    body: Readable.toWeb(createReadStream(local)),
    duplex: "half",
    signal: AbortSignal.timeout(600_000),
  },
);
if (!response.ok) throw new Error(await response.text());
console.log((await response.json()).path);
```

```bash curl theme={"dark"}
# Requires jq.
curl --fail-with-body --silent --show-error -T "$UPLOAD_FILE" \
  -H "Authorization: Bearer $TILION_API_KEY" \
  "https://back.tilion.dev/v1/session/$TILION_SESSION_ID/files/$(basename "$UPLOAD_FILE" | jq -sRr 'rtrimstr("\n") | @uri')" \
  | jq -r .path
```

## Attach it to a file input

Pass the returned path to `DOM.setFileInputFiles` from a Playwright CDP session. Set `CONNECT_URL` to the session's `connect_url` and `UPLOADED_PATH` to the printed path, and open the page with the file input first.

```python Python theme={"dark"}
import os
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(os.environ["CONNECT_URL"], timeout=60000)
    page = browser.contexts[0].pages[0]
    cdp = page.context.new_cdp_session(page)
    root = cdp.send("DOM.getDocument")["root"]["nodeId"]
    node = cdp.send("DOM.querySelector", {"nodeId": root, "selector": "input[type=file]"})
    cdp.send("DOM.setFileInputFiles", {
        "files": [os.environ["UPLOADED_PATH"]], "nodeId": node["nodeId"],
    })
    browser.close()
```

```javascript Node.js theme={"dark"}
import { chromium } from "playwright-core";

const browser = await chromium.connectOverCDP(process.env.CONNECT_URL, { timeout: 60_000 });
const page = browser.contexts()[0].pages()[0];
const cdp = await page.context().newCDPSession(page);
const { root } = await cdp.send("DOM.getDocument");
const { nodeId } = await cdp.send("DOM.querySelector", {
  nodeId: root.nodeId, selector: "input[type=file]",
});
await cdp.send("DOM.setFileInputFiles", { files: [process.env.UPLOADED_PATH], nodeId });
await browser.close();
```

## Collect a download

Encode a file's name when you put it in a URL. A `#` or `?` that isn't encoded ends the path, so the request reaches a different file.

After a click starts a download, poll the list until a file is finished, then read it. `in_progress` is true while Chrome is still writing. Each example saves the first finished file.

```python Python theme={"dark"}
import os
import time
from urllib.parse import quote
import requests

base = f"https://back.tilion.dev/v1/session/{os.environ['TILION_SESSION_ID']}/downloads"
headers = {"Authorization": f"Bearer {os.environ['TILION_API_KEY']}"}

done = []
for _ in range(60):
    listing = requests.get(base, headers=headers, timeout=30)
    listing.raise_for_status()
    done = [f for f in listing.json()["files"] if not f["in_progress"]]
    if done:
        break
    time.sleep(2)
if not done:
    raise RuntimeError("no finished download")

name = done[0]["name"]
with requests.get(f"{base}/{quote(name, safe='/')}", headers=headers, stream=True, timeout=600) as response:
    response.raise_for_status()
    with open(os.path.basename(name), "wb") as out:
        for chunk in response.iter_content(1024 * 1024):
            out.write(chunk)
print("Saved", name)
```

```javascript Node.js theme={"dark"}
import { createWriteStream } from "node:fs";
import { basename } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises";

const base = `https://back.tilion.dev/v1/session/${process.env.TILION_SESSION_ID}/downloads`;
const headers = { Authorization: `Bearer ${process.env.TILION_API_KEY}` };

let done = [];
for (let i = 0; i < 60 && done.length === 0; i++) {
  if (i > 0) await sleep(2000);
  const listing = await fetch(base, { headers, signal: AbortSignal.timeout(30_000) });
  if (!listing.ok) throw new Error(await listing.text());
  done = (await listing.json()).files.filter(file => !file.in_progress);
}
if (done.length === 0) throw new Error("no finished download");

const { name } = done[0];
const path = name.split("/").map(encodeURIComponent).join("/");
const response = await fetch(`${base}/${path}`, { headers, signal: AbortSignal.timeout(600_000) });
if (!response.ok) throw new Error(await response.text());
await pipeline(Readable.fromWeb(response.body), createWriteStream(basename(name)));
console.log("Saved", name);
```

```bash curl theme={"dark"}
# Requires jq. Repeat the list until a finished file appears.
BASE="https://back.tilion.dev/v1/session/$TILION_SESSION_ID/downloads"
NAME=$(curl --fail-with-body --silent --show-error "$BASE" \
  -H "Authorization: Bearer $TILION_API_KEY" \
  | jq -r '[.files[] | select(.in_progress | not)][0].name // empty')
NAME_PATH=$(printf %s "$NAME" | jq -sRr 'split("/") | map(@uri) | join("/")')
curl --fail-with-body -o "$(basename "$NAME")" "$BASE/$NAME_PATH" \
  -H "Authorization: Bearer $TILION_API_KEY"
```

> [!NOTE]
>
> With Playwright connected, a download's name in the list is `playwright-downloads/<folder>/<GUID>`, so the saved file takes the GUID as its name. Rename it with Playwright's `suggested_filename`. The examples above read these names as they are.

Files end with the session, so copy what you need before closing it. See [Files and downloads](https://docs.tilion.dev/sessions/files.md) for the delete routes, what is not listed, and error codes.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.