Skip to main content
A storage provider is where stored content lives: templates, uploaded files, and anything the ResourceManager reads or writes. One provider is active per server, named by storage_module in the manifest.
Local storage is per-process state on one machine’s disk. A multi-replica fleet gives each replica its own copy — use S3 or GitHub instead.

Install and configure one

Each provider’s package, its storage_module manifest key, its STORAGE_<KIND>_* settings, and its store-specific setup (S3 bucket policy and MinIO addressing, GitHub token scopes and the 1 MiB / CDN-staleness limits) live on the plugin page:

storage-local

STORAGE_LOCAL_* — a root directory on local disk, required: every call refuses until it is set.

storage-s3

STORAGE_S3_*, the bucket policy, and MinIO.

storage-github

STORAGE_GITHUB_*, token scopes, and the write/read caveats.

Inbound media

When a participant sends a file over the conversation bridge — a photo, document, voice or audio clip, or video — the platform fetches the bytes, verifies them, and stores them by reference through the active storage provider, then serves them back from a same-origin capability URL that carries an unguessable id. Ingestion therefore needs a registered storage provider (any of the three above). The default self-hosted image bakes and selects storage-local, so inbound media works out of the box; register no provider and an inbound file is rejected loudly — the sender is told “That attachment could not be received.” (or the route’s reply_texts.rejected_could_not_receive) — rather than dropped in silence. Per-kind size ceilings are MEDIA_INGEST_* settings — MEDIA_INGEST_MAX_IMAGE_BYTES (8 MiB), MEDIA_INGEST_MAX_AUDIO_BYTES (16 MiB), MEDIA_INGEST_MAX_VIDEO_BYTES (25 MiB), and MEDIA_INGEST_MAX_DOCUMENT_BYTES (25 MiB) by default; the settings reference carries every MEDIA_INGEST_* variable and its value. Which files are accepted depends on their kind: photos, audio, and video are stored only when their bytes carry a recognised signature that matches an allowlisted type — a file that merely claims to be an image is rejected — while documents of text-like allowlisted types (such as plain text or CSV) are accepted by their declared type and are always served as a download, never as active content. Active content such as SVG, HTML, or JavaScript is on no allowlist and is never accepted. A stored inbound file is retained for the life of its conversation record (30 days by default): the platform records each file’s expiry in a durable index and a background reaper walks that index to delete past-horizon files, so nothing is held forever — a file that outlives its own record is still reaped. Inbound media is stored under the inbound-media/ prefix, which the platform marks not backed up and unlisted. A backup export leaves it out and a restore refuses an entry under it. It shows in no listing — not the Studio Storage screen, not the Studio pickers, not tai storage list or tai templates list — yet each file is still read, downloaded and deleted by its id. A plugin marks a prefix of its own the same way, under either mark or both, with tai42_app.storage.mark_not_backed_up(prefix) and tai42_app.storage.mark_unlisted(prefix). The prefix is a canonical store key followed by / (such as inbound-media/); any other value raises ValueError. Store keys are canonical at every door, so a key is under a prefix exactly when it starts with it. A mark lasts one serving epoch: declare it from a startup handler, which runs at boot and again at every reload.

See also