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.mp4

    The 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 fetched turn, 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

ToolCapabilityWhat it does
file_sendnotification:sendSends 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_requestfile:requestAsks 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_downloadfile:fetchFetches 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_publishartifact:publishPublishes 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
KeyDefaultWhat it decides
maxUploadBytes2 GiBLargest 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
maxDownloadBytes2 GiBLargest file the engine downloads (file_download, an inbound Telegram file)
maxPublishBytes2 GiBLargest file artifact_publish stores; also the link rung’s ceiling
transferStallMs60000A transfer is aborted after this long without moving a byte — a stall leash, never a total deadline. 0 = no leash
linkTtlHours72How long a download link the engine mints stays valid (file_send’s link rung)
uploadRequestTtlHours24How long a file_request page takes files; an upload still in flight at expiry is aborted
freeSpaceReserveBytes2 GiBFree 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
videoAsvideoauto sends a video as a playable video or as a document
linkServeredirectA download link answers a short-lived signed URL of the store (redirect, when the store can sign) or proxies the bytes (stream)
uploadAcceptvideo/*, audio/*, image/*, application/pdf, text/*Types an upload page takes

Delivery transports

IdCarries up toNeeds
bot-api50 MBnothing
bot-mtproto2 GBTELEGRAM_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
account4 GB (Premium), else 2 GBa connected Telegram account in the chat (telegram-account.md)
drive5 TBa Google connection; posts a Drive share link
linkmaxPublishBytesARTIFACTS_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 PUT from the widgets origin (WIDGETS_PUBLIC_URL) with ExposeHeaders: [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.

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.

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).

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.