Skip to content

File uploads

Receipts, ID scans, evidence for a claim. Files go to your own bucket — AWS S3, Cloudflare R2, MinIO, 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:

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 bucket, an upload is refused with a message naming the secrets to set.

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.