Files of any size
A person sends the bot a 1.5 GB video; an agent asks a client for a scan from outside Telegram; an agent hands back a cut that is too large for the chat. The engine moves each of these as a blob ref and picks the transport by size: bytes never enter a transcript, a queue payload or a tool result — the agent sees a path on disk or a ref, the person sees a file, a playable video or a link.
Everything here works with zero configuration. What you decide is listed below, each with the engine default.
What the agent sees
-
A file a person sends in a chat under the Bot API’s limit arrives with the message, as today. A larger one (a video, a document) is announced in the turn —
video.mp4, 1.4 GB — arriving: not attached here, it is being fetched in the background and reaches you as a separate [file] message— and the chat goes on. When the download finishes the agent gets a turn of its own:[file] video.mp4 — 1.4 GB, video/mp4, sent in the chat. Attached files: - /artifacts/blobs/…/video.mp4The path is a local file the agent’s own tools read (
ffmpeg,audio_transcribe,document_read). A file that cannot arrive (over the size cap, or no transport could fetch it) still gets its[file] … could not be fetchedturn, so nothing waits forever. Voice and audio stay inline, transcribed in the turn. -
An API agent (not a command-line one) gets an image or a PDF inlined only under its model cap (32 MiB for a PDF), a text file as its first part; anything larger by its path.
-
Every
[file]turn is served alone and exactly once per file, whichever door it came from: the chat, an upload page (file_request) or a download (file_download).
The tools
| Tool | Capability | What it does |
|---|---|---|
file_send | notification:send | Sends a file to the chat from exactly one of path (a file in the agent’s artifacts dir), ref (a blob ref a tool answered) or content (text the agent composed, sent as a UTF-8 file — name required, its extension sets the type, text/plain otherwise; the way an API-mode agent, which cannot write into its artifacts dir, sends a .md / .csv / .ics), name?, kind (auto · document · video · audio · photo), caption?, linkExpiresInHours?. Walks the delivery ladder (below) and answers which transport carried it: { transport, messageId, url, expiresAt, … }. auto sends a video as files.videoAs. |
file_request | file:request | Asks a person for a file: posts the engine’s upload page (ui://engine/upload) in the chat as a button, or — launch: false — answers a url to hand over yourself (email, WhatsApp; opened in a browser, the person signs in with Telegram). The page uploads in resumable parts; each file reaches the asking agent as a [file] turn. accept, maxBytes, expiresInHours narrow the policy; key makes asking twice answer the open request. The answer’s requestId is the page’s widget id: widget_get lists what arrived, widget_close ends it. |
file_download | file:fetch | Fetches a URL or a share link (Dropbox, Google Drive, …) in the background and answers a jobId at once; the file reaches the agent as a [file] turn. Public addresses only; resumes a dropped connection where the server allows. |
artifact_publish | artifact:publish | Publishes a file as a link (artifact-publishing.md), up to files.maxPublishBytes. |
file_request and file_download are moderate, no approval card by default. To put a person in
front of either, set it in constitution/engine-capabilities.json
({ "file:fetch": { "hitl": true } }); grant it to a role through its capabilities: like any
other.
The files policy
One block, four tiers, the nearest winning per key: engine default → constitution/MANIFEST.yaml
§ files (hot: an agent changes it with manifest_update) → a role’s top-level files: block in
its *.agent.md → a tool’s own argument, which can only narrow (a maxBytes or a link expiry
above the policy is cut to it).
# constitution/MANIFEST.yaml
files:
maxUploadBytes: 1073741824 # 1 GiB
delivery: [bot-api, bot-mtproto, link]
linkTtlHours: 24
| Key | Default | What it decides |
|---|---|---|
maxUploadBytes | 2 GiB | Largest file a person may upload — the ceiling of every page upload (a widget’s own widgets.maxUploadBytes only narrows it for its page) and of the dashboard composer |
maxDownloadBytes | 2 GiB | Largest file the engine downloads (file_download, an inbound Telegram file) |
maxPublishBytes | 2 GiB | Largest file artifact_publish stores; also the link rung’s ceiling |
transferStallMs | 60000 | A transfer is aborted after this long without moving a byte — a stall leash, never a total deadline. 0 = no leash |
linkTtlHours | 72 | How long a download link the engine mints stays valid (file_send’s link rung) |
uploadRequestTtlHours | 24 | How long a file_request page takes files; an upload still in flight at expiry is aborted |
freeSpaceReserveBytes | 2 GiB | Free disk the local store keeps: a transfer that would leave less is refused before a byte is written (a transfer that declares no length counts as its size cap). 0 = none |
delivery | [bot-api, bot-mtproto, link] | Transports file_send tries, in order (below) |
inbound | [bot-api, bot-mtproto, account] | Transports a file a person sends is fetched over, in order |
videoAs | video | auto sends a video as a playable video or as a document |
linkServe | redirect | A download link answers a short-lived signed URL of the store (redirect, when the store can sign) or proxies the bytes (stream) |
uploadAccept | video/*, audio/*, image/*, application/pdf, text/* | Types an upload page takes |
Delivery transports
| Id | Carries up to | Needs |
|---|---|---|
bot-api | 50 MB | nothing |
bot-mtproto | 2 GB | TELEGRAM_MTPROTO_API_ID / TELEGRAM_MTPROTO_API_HASH — the bot logs in over MTProto with its own token; no account. Not in business chats or forum topics |
account | 4 GB (Premium), else 2 GB | a connected Telegram account in the chat (telegram-account.md) |
drive | 5 TB | a Google connection; posts a Drive share link |
link | maxPublishBytes | ARTIFACTS_PUBLIC_URL; posts a public link that expires after linkTtlHours |
A rung that is unavailable in this chat, or too small for the file, is skipped; a send that fails falls over to the next one. A file over every rung’s ceiling is refused before anything is copied.
Inbound transports
bot-api takes files up to 20 MB, bot-mtproto the rest in the bot’s DMs and groups (same app
credentials as above), account a chat a connected account shares. Without the app credentials
and an account, a large file in a bot DM gets its “could not be fetched” turn.
Storage
Blobs live in the local store ($ARTIFACTS_ROOT/blobs) unless the brain declares a bucket in
omnislash.config.ts:
export default defineProject({
storage: {
provider: 's3',
endpoint: 'https://<account>.r2.cloudflarestorage.com',
bucket: 'my-brain-files',
region: 'auto',
forcePathStyle: true, // most non-AWS stores
minBytes: 8 * 1024 * 1024, // smaller files stay local; absent = every file to the bucket
},
});
The keys are env, never in the config: STORAGE_S3_ACCESS_KEY_ID, STORAGE_S3_SECRET_ACCESS_KEY.
Use a bucket of its own: the engine writes blobs/ (content-addressed blobs/<sha256>-<name>; an
upload is keyed blobs/<uuid>-<name>, not re-hashed), trash/ (garbage collection’s quarantine)
and uploads/ (upload sessions). The bucket needs:
- CORS allowing
PUTfrom the widgets origin (WIDGETS_PUBLIC_URL) withExposeHeaders: [ETag]— an upload page sends its parts straight to the bucket; - a lifecycle rule
AbortIncompleteMultipartUpload(a day).
omnislash doctor --check blob-storage checks the keys, the bucket, CORS and the lifecycle rule.
The blob GC sweeps the local store and the bucket alike; refs already written stay readable when
the store changes.
file-capacity (runtime doctor) checks the artifacts volume against the policy: room for one
full-size file plus the reserve, and an artifactsRetention.maxSizeMb that holds one — large
downloads and materialized copies live there until they are used.
Share links
file_download turns a share page into the file’s own address with resolvers. The engine’s
(defaultShareResolvers from @omnislash-ai/config/public) read Dropbox (dl=1) and Google Drive
(the Drive API through a brain-declared google OAuth provider, else the public download). A brain
adds its own and keeps the engine’s:
import { defaultShareResolvers, defineProject, type ShareLinkResolver } from '@omnislash-ai/config/public';
const wetransfer: ShareLinkResolver = {
id: 'wetransfer',
match: url => url.hostname === 'we.tl',
resolve: async (url, { signal }) => ({ url: await directLinkOf(url, signal) }),
};
export default defineProject({ files: { resolvers: [wetransfer, ...defaultShareResolvers] } });
files.resolvers replaces the list wholesale: [] resolves nothing.
Links
A published file is served by its unguessable /p/<id> with byte ranges (a video seeks, a dropped
download resumes). With a bucket and linkServe: redirect the link answers a redirect to a signed
URL minted for that request (10 minutes) — the link itself never expires sooner than you set, and no
signed URL ever reaches an agent or a chat. private publications are for admins (they need the
dashboard login); a link for a client is public + the unguessable id + an expiry
(artifact-publishing.md).