Storage
Files for your app's users, in storages you create, governed by the same policies and organizations as your data.
Your app can store files: avatars, invoice PDFs, CSV exports, anything your users upload or your handlers produce. Files live in storages you create in the console, inside folders, and every read and write is authorized against your app's policies, exactly as a row is.
There are three ways in, all onto the same rules:
| Door | Who uses it |
|---|---|
The REST API, /{appUid}/storage/... | Your app's own users, from your frontend or backend. See Uploading and downloading. |
sdk.storage | Your handlers. |
| Agent tools | Your agents, derived from the same permissions as the REST API. |
The console's Storage section is the fourth: you, the app's owner, browsing and managing every file.
Storages
A storage is a named container for files, such as Avatars, Invoice scans or Exports. Create
as many as you like: storages themselves are free, and what counts against your plan is the total
bytes stored.
To create one, open the app's Storage section and choose New storage:
| Setting | What it does |
|---|---|
| Name | Shown in the console and returned by the API. Unique within the app. |
| Access | Private (the default): links to a file are signed and expire. Public: links are permanent and anyone who has one can open the file. |
| Max file size | Rejects any single file larger than this. It caps each upload, not the storage's total. Leave it empty to use your plan's limit. |
| Allowed file types | Comma-separated MIME types, with wildcards: image/*, application/pdf. Leave it empty to allow every type. |
Each storage has a uid, which every API call that works inside it takes as storageUid. Find it
by listing the storages over the API, or with
sdk.storage.listStorages() in a handler.
Separate storages are how you give different files different rules. Avatars belong in a public
storage limited to image/*; signed contracts belong in a private one limited to
application/pdf. Permissions apply to all of your storages alike (see below), so use them to
separate what is visible, not who may use it.
Public or private
Access is a property of the storage, so every file inside it follows it. There is no per-file setting: you can tell what a storage exposes at a glance.
| Private | Public | |
|---|---|---|
| A link to a file | Signed, and expires (15 minutes unless you ask for longer) | Permanent |
| Can a browser or CDN cache it | No | Yes |
| Who can open it | Whoever holds the link, until it expires | Anyone who has the link, without signing in |
| Use it for | Documents, user uploads, anything governed | Avatars, logos, product images |
Signing is right for a document and wrong for an avatar: a signed URL changes on every render, so nothing can cache it, and a 15-minute link in an email is dead before it is opened. That is what public storages are for. Don't put anything user-specific or confidential in one.
Switching a public storage back to private stops its permanent links from working at once. There is nothing to revoke and no cache to wait out.
Folders and files
Inside a storage, files sit in a tree of folders, each named by its uid and its path
(/2026/q3). The storage's root is the folder with no uid.
- Names are a single path segment: no
/or\, not.or.., at most 255 characters. - Two files with the same name in one folder are kept apart automatically: a second
report.pdfis stored asreport (1).pdf. Two folders with the same name are refused with409. - Every file has an owner: the user it belongs to, returned as
owner_uid. A file a user uploads is theirs by default, which is what makes "users may only open their own files" a policy rather than code (below). - A file can carry metadata: a JSON object of your own, such as
{ "invoice_uid": "0199b8e4-…", "source": "email" }, returned with the file.
Deleting a file is soft by default: it disappears from every listing but its bytes are kept. A hard delete removes the bytes too, and cannot be undone.
Who can do what
Storage permissions are three switches in a policy, under People using the app:
| Switch | Grants | Policy keys |
|---|---|---|
| Read files | List storages and folders, read a file's details, get a link, download | storage:read_list, storage:list, storage:read |
| Upload files | Upload, create folders, rename, move, change metadata or owner | storage:upload, storage:presign, storage:confirm, storage:create_folder, storage:update |
| Delete files | Delete a file | storage:delete |
Reading and uploading are separate on purpose: you can let users fetch files without letting them add any. An agent whose role holds these keys gets the same abilities as tools, with no tool to write.
Limiting users to their own files. In the policy's advanced view, add the isOwner condition
to the actions that name one file: storage:read, storage:update and storage:delete. A user
can then open, change and delete only files whose owner_uid is theirs. Don't add it to the
listing actions (storage:read_list, storage:list): a listing names no single file, so isOwner
would refuse it outright.
Organizations
Your app's users each act in one of your app's organizations, and storage follows the same line as
your tables. Your storages are shared containers: every organization can use them, and each sees
only its own folders and files inside. Another organization's file is the same 404 as one that
does not exist.
Your own folders and files, the ones you add in the console, are private to you until you share them with some or all organizations. See Organizations and sharing.
Limits
- Total bytes. Everything stored counts against your workspace's storage, shared across your
apps. An upload that would go over it is refused before any bytes are written, with
402andSUBSCRIPTION_LIMIT_REACHED. - Per file. The largest file your plan allows, tightened by the app's own setting and by the
storage's Max file size. A file over the limit is
413withFILE_TOO_LARGE. - File types. A type outside the storage's allowed list is
415withMIME_NOT_ALLOWED.
From handlers
sdk.storage in a handler works on the same storages, folders and files, under the same rules:
export default async function attachScan(data = {}, ctx) {
const { sdk } = ctx;
const file = await sdk.storage.upload(data.storageUid, {
name: `${data.invoiceNumber}.pdf`,
contentType: "application/pdf",
body: data.pdfBase64,
encoding: "base64",
path: "/2026/q3", // created if missing
metadata: { invoice_uid: data.invoiceUid },
});
const { url, kind, expiresAt } = await sdk.storage.url(file.uid);
return { fileUid: file.uid, url, kind, expiresAt };
}Reading files (list, get, url, download, search) needs the handler's
sdk:storage_read capability, which is granted by default. Anything that writes or spends storage
(upload, uploadFromUrl, createFolder, move, rename, update, delete) needs
sdk:storage_write, which is not. See Capabilities and the
Handler SDK reference for every method.