/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 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"]) }

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 SDK helpers above are Python-only. JavaScript tools get logging via require('artifuncs-log') but 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 }.

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.