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_REGIONThe 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
- 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.
- The browser
PUTs the file straight to your bucket, under<session>/<upload>— never the file name, which may itself be personal. - 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.