/docs/Development/Files
back to app →

Files

A file field lets your tool take a file from the user or hand one back. The important thing to understand up front: file bytes never travel inside the input/output JSON. Only filenames do. The actual bytes live in a per-tool files directory on the sandbox, and your code reads and writes them through the SDK.

This page covers the full round-trip. For declaring the field itself, see Fields; for the process contract, see Entry point.

The files directory

Every tool gets its own files directory on the sandbox. Uploaded inputs land there, and anything you write there can be served back to the browser. You never need to know its absolute path — the SDK resolves it for you from the ARTIFUNCS_FILES_DIR environment variable.

In anonymous / multi-tenant sandboxes, every visitor session gets its own separate files directory, so two people running the same tool never see each other's files. That isolation is handled for you — as long as you go through the SDK rather than hardcoding paths.

Filenames are always reduced to their base name (path components are stripped). You can't read or write into subdirectories, and ../ traversal is blocked. Pass plain names like "report.pdf", not "out/report.pdf".

File inputs

Declare a file input field in artifuncs.json:

json
"fields": {
  "input": {
    "upload": {
      "type": "file",
      "label": "Upload CSV",
      "accept": ".csv",
      "maxSize": 5242880
    }
  }
}

When the user picks a file, the browser uploads it to the sandbox before the run starts. By the time process is called, the file is already on disk and the value you receive in input is just its filename as a string:

python
input == { "upload": "data.csv" }

Read its bytes with get_file_content:

python
from artifuncs.sandbox_sdk import get_file_content

def process(input, settings):
    raw = get_file_content(input["upload"])   # -> bytes
    rows = raw.decode("utf-8").splitlines()
    return { "count": len(rows) }

get_file_content returns the raw bytes. Decode them yourself if you want text.

Multiple files

With "multiple": true, the field value is a list of filename strings instead of a single one:

python
input == { "uploads": ["data.csv", "extra.csv"] }

Iterate over the list and read each name the same way:

python
def process(input, settings):
    total = sum(len(get_file_content(name)) for name in input["uploads"])
    return { "total_bytes": total }

File outputs

Declare a file field under fields.output — same type, just in the output block:

json
"fields": {
  "output": {
    "result": {
      "type": "file",
      "label": "Processed file"
    }
  }
}

Write the bytes with save_file_content, then return what it gives you under the output field's name:

python
from artifuncs.sandbox_sdk import save_file_content

def process(input, settings):
    pdf_bytes = build_pdf(...)
    return { "result": save_file_content("report.pdf", pdf_bytes) }

save_file_content writes the file to the files directory and returns a small dict:

python
{ "path": "report.pdf", "size": 20481 }

That { path, size } shape is exactly what a file-output field expects. The UI turns it into a download button — clicking it fetches the file back from the sandbox by name. If you return the wrong shape (e.g. just the byte string), the output renders empty.

If the file is a picture, declare the output as image instead of file — the same { path, size } value renders the image inline with the download button in its top-right corner.

If the file is already on disk — for example you wrote it some other way, or you're echoing back an input — use get_file_info to produce the same { path, size } dict without re-reading the bytes:

python
from artifuncs.sandbox_sdk import get_file_info

def process(input, settings):
    return { "result": get_file_info(input["upload"]) }

Assets

The files directory is per-run — inputs land there, outputs are read back from there, and for anonymous visitors it's per-session. Anything you write is gone by the next run.

For things that are expensive to fetch and cheap to reuse — model weights, lookup tables, fonts — use assets instead. They survive runs and redeploys:

python
from artifuncs.sandbox_sdk import download_asset, asset_exists, asset_path, delete_asset

MODEL_URL = "https://example.com/u2netp.onnx"

def init():
    if not asset_exists("u2netp.onnx"):
        download_asset(MODEL_URL, "u2netp.onnx")

def process(input, settings):
    session = load_model(asset_path("u2netp.onnx"))
    ...

download_asset() streams straight to disk — the file never exists in memory in full, so a 500 MB model costs one chunk of RAM rather than 500 MB. That matters: the default sandbox has 1 GB. It also writes to a temporary file and renames on completion, so an interrupted download can't leave a truncated file that asset_exists() would report as ready.

The host must be in your network allowlist or the request is blocked — including whatever the host redirects to, since most model hosts hand off to a CDN:

json
"network": {
  "allow": ["github.com", "objects.githubusercontent.com"]
}

network is an object with an allow array. A bare array is ignored and means deny‑all.

FunctionPurpose
download_asset(url, name)Stream a URL to storage. Returns { "name", "size", "cached" }; cached is True when it was already there and nothing transferred.
asset_exists(name)Whether it's already stored. Guard your download with this.
asset_path(name)Absolute path, for handing to a library that wants a filename.
asset_info(name){ "name", "size" }.
list_assets()Every stored asset name.
delete_asset(name)Remove one. Returns False if it wasn't there.

Assets are confined to their own directory: every one of these strips directory components from the name, so a func can't read or delete its own source, its dependencies, or another func's data. delete_asset("../main.py") deletes nothing and returns False.

Because assets outlive a deploy, init() re-running after an IDE save costs nothing — asset_exists() short-circuits it. If you need to force a re-download (a model you've replaced upstream), call delete_asset() first.

SDK reference

All helpers import from artifuncs.sandbox_sdk and operate on the tool's files directory. They raise RuntimeError if called outside a sandbox.

FunctionReturnsNotes
get_file_content(filename)bytesRaw file content. Raises FileNotFoundError if missing.
save_file_content(filename, content){ "path", "size" }content must be bytes. Creates the files directory if needed. Return this for a file-output field.
get_file_info(filename){ "path", "size" }Same shape as save_file_content, but for a file already on disk. Raises FileNotFoundError if missing.
list_files()list[str]Filenames currently in the directory.
file_exists(filename)boolCheap existence check.

End-to-end example

A tool that takes an uploaded image and returns a thumbnail:

json
"fields": {
  "input": {
    "image": { "type": "file", "label": "Source image", "accept": "image/*" }
  },
  "output": {
    "thumb": { "type": "file", "label": "Thumbnail" }
  }
},
"settings": {
  "size": { "type": "number", "label": "Max dimension", "default": 256 }
}
python
import io
from PIL import Image
from artifuncs.sandbox_sdk import get_file_content, save_file_content, log

def process(input, settings):
    img = Image.open(io.BytesIO(get_file_content(input["image"])))
    img.thumbnail((settings["size"], settings["size"]))

    buf = io.BytesIO()
    img.save(buf, format="PNG")
    log.info(f"thumbnail {img.size}")

    return { "thumb": save_file_content("thumb.png", buf.getvalue()) }

(Pillow goes in requirements.txt — see Entry point.)

JavaScript tools

The file helpers above are Python-only: JavaScript tools handle files directly with Node's fs, reading the files directory from process.env.ARTIFUNCS_FILES_DIR. The contract is the same — input.field is a filename, and a file-output field expects { path, size }.

The asset helpers, by contrast, exist in both languages. JavaScript gets them from require('artifuncs-sdk') (injected by the sandbox — don't add it to package.json) as downloadAsset, assetPath, assetExists, assetInfo, listAssets and deleteAsset, alongside progress. Logging remains require('artifuncs-log').

js
const { downloadAsset, assetExists, assetPath } = require('artifuncs-sdk')

export async function init() {
    if (!assetExists('model.onnx')) await downloadAsset(MODEL_URL, 'model.onnx')
}
js
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'

export function process(input, settings) {
    const dir = process.env.ARTIFUNCS_FILES_DIR
    const raw = readFileSync(join(dir, input.upload))

    const out = 'result.txt'
    const content = Buffer.from(raw.toString().toUpperCase())
    writeFileSync(join(dir, out), content)

    return { result: { path: out, size: content.length } }
}

What gets sent over the wire

To recap the round-trip:

  1. The browser uploads the chosen file to the sandbox before the run.
  2. process receives the filename, not the bytes.
  3. Your code reads the bytes from the files directory (via the SDK in Python, fs in JS).
  4. Your code writes any output file to the same directory and returns { path, size }.
  5. The browser downloads the output file back from the sandbox by name when the user clicks the file-output's download button.

Because only filenames cross the boundary, large files don't bloat the input/output payload — they move as plain HTTP uploads and downloads.