Skip to content

File uploads ​

Receipts, ID scans, evidence for a claim. Files go to your own bucket — AWS S3, Cloudflare R2, Garage, anything S3-compatible. The engine never carries the bytes: the browser uploads straight to the bucket, and the engine checks the file and records it in the ledger.

Try it

An expense claim with a receipt upload in the playground runs in your browser. Play every role from the toolbar.

tsx
<Field name="receipt" type="file" label="Receipt" accept=".pdf,image/*" maxSize={10_000_000} required />
<Field name="photos" type="file" multiple />
  • accept — the input's attribute: extensions (.pdf), wildcards (image/*), exact types.
  • maxSize — bytes. The engine also has its own ceiling of 100 MB.
  • multiple — keep several files. Without it, a new upload replaces the file.

The default component uploads, lists the files with their size, opens and removes them. In the browser-only sandbox, files stay in the browser.

Connect your bucket ​

Set these secrets on each environment, in the dashboard (Secrets → File storage, with a Test bucket button that writes, reads and deletes a small object) or with the CLI:

bash
printf %s "my-bucket"      | bunx clipless secrets set STORAGE_BUCKET
printf %s "$ACCESS_KEY_ID" | bunx clipless secrets set STORAGE_ACCESS_KEY_ID
printf %s "$SECRET_KEY"    | bunx clipless secrets set STORAGE_SECRET_ACCESS_KEY
# Not AWS? Where the S3 API is, and its region if it wants one:
printf %s "https://<account>.r2.cloudflarestorage.com" | bunx clipless secrets set STORAGE_ENDPOINT
printf %s "auto" | bunx clipless secrets set STORAGE_REGION

The key needs PutObject, GetObject and DeleteObject on the bucket. Because browsers upload to it directly, the bucket needs a CORS rule allowing PUT (with a content-type header) and GET from your app's origin.

Without a working bucket, an upload is refused, and the person filling in the form is told only that uploads aren't available right now. Your dashboard's overview says which workflows take files and what is missing.

What happens ​

  1. The browser asks the engine to upload one file into a field. The engine checks that this role owns the stage, the stage is open, and the file's type and size fit the field. It answers with a presigned URL, good for 15 minutes.
  2. The browser PUTs the file straight to your bucket, under <session>/<upload> — never the file name, which may itself be personal.
  3. The browser says it is done. The engine measures the object, hashes it (SHA-256), and writes the field's new value itself. A file that turns out larger than allowed is deleted and refused.

The answer holds references, and the hash puts each file in the ledger's tamper-evident chain:

json
[{ "pid": "Vq3…", "name": "taxi.pdf", "type": "application/pdf", "size": 48213, "sha256": "9f2c…" }]

Only the engine adds a file. A client may send a file field's value with files taken out, and it is accepted — but only files the engine already recorded, as the engine recorded them. Anything else is refused, so a browser cannot put a reference to an object it did not upload, or rewrite a name or hash.

Your own component ​

tsx
function Evidence() {
  const f = useFiles() // inside <Field type="file">, or useFiles('photos') anywhere
  return (
    <>
      <input type="file" multiple onChange={(e) => f.upload(e.target.files)} disabled={f.uploading} />
      {f.files.map((file) => (
        <div key={file.pid}>
          {file.name}
          <button onClick={async () => window.open((await f.url(file.pid)) ?? '', '_blank')}>Open</button>
          <button onClick={() => f.remove(file.pid)}>Remove</button>
        </div>
      ))}
      {f.error ? <p>{f.error}</p> : null}
    </>
  )
}

url(pid) gives a link valid for 5 minutes, and only to a role that may read the field (visibility applies to files as to any answer).

Removing and erasing ​

Removing a file from an answer keeps the object: earlier rounds of the session still reference it, and the ledger is history. Erasing a session deletes its objects from your bucket; the ledger keeps the references, and reading one answers 410.

Files are not carried into the next run of a recurring workflow, and cannot be given in prefill — only an upload puts a file into an answer.