Open the console

Concepts

Files and multi-modal

An invocation carries text. So a message cannot contain a PDF — it can only name one that has already been uploaded, and something has to turn that name back into bytes before the model sees it. That something is on by default.

The three steps

Uploading is a two-call handshake, because the bytes never pass through this platform. Your backend declares what is about to be sent and gets back a short-lived URL, and whoever holds the file sends it there directly with a PUT.

The client declares an upload and receives a signed URL, PUTs the bytes straight to storage, and then invokes with a message that names the file. The platform loads it back out of the agent's own artifact store and appends it to that turn as a real file, so the model receives the document rather than its name.Declarename, type, exact sizePUT the bytesstraight to storageInvokea message naming itThe modelreceives the filea signed URLnow an artifactloaded, attached
The client declares an upload and receives a signed URL, PUTs the bytes straight to storage, and then invokes with a message that names the file. The platform loads it back out of the agent's own artifact store and appends it to that turn as a real file, so the model receives the document rather than its name.

The declared size is the authorization: the signed URL pins the content length to exactly that number, so an upload that does not match is refused by storage rather than accepted and truncated. A single file may be up to 512 MiB.

Your backend declares; a browser only uploads

A ticket — the one credential an end user's browser holds — is refused on every file route, so a browser can never declare its own upload. When the file is on somebody's screen, your backend declares it with its own credential, naming that user, and hands the browser the signed URL and its headers to PUT to. That hop is where a per-user quota, a type check or a scan belongs. A browser upload also needs the page's origin listed under the project's artifacts.origins in elan.yaml, or the PUT fails as an opaque network error. When your backend holds the bytes itself, the SDK's upload does both calls in one. The upload URL is not on this platform, so never send your platform token to it.

An upload is an artifact

The object lands exactly where the agent framework would have written it itself. So an upload is not a separate kind of thing with its own API — it is an artifact, which means your own tool code finds it with load_artifact(filename) with no new code, and an agent can discover it unprompted by listing what it holds.

The four scopes

A scope is addressed by a filename prefix, which is the framework's own idiom rather than something standing beside it — user: already meant “widen this beyond one conversation”, and two more prefixes reach two more scopes through the same call.

Written asScopeWho can read itWho can write it
project:policy.pdfthe whole projectevery target in the projecta tenant admin
agent:rates.csvone target, all its usersthat targeta tenant admin
user:scan.pdfone end user, across their conversationsthat target, for that userthe agent, and your backend naming the user
scan.pdfone conversationthat session onlythe agent, and your backend naming the user

“Your backend” there means any credential for the project: naming one of your users on a file route does not need the admin role. A bare filename is the session scope, which is why a name that comes back from a listing keeps its prefix: handing policy.pdf back without the project: would resolve to a different file, or to nothing.

The two shared scopes exist so a tenant can maintain a document once — a policy, a rate sheet — and have every agent read it. They are read-only to an agent, and they are sibling locations rather than nested ones, because storage permissions are additive: anything inside a location the agent can write could only ever gain permissions, never lose them, so a nested “shared” folder could not be made read-only.

What separates users is the runtime, not a credential

The project and target scopes are enforced by storage permissions. The user and session scopes are enforced by the platform's own code only — one pod holds one identity and serves every user of that agent, so nothing at the storage layer can tell two of its users apart. A compromised agent reaches any of its own users' files, and no other agent's and no other tenant's. If two agents must not reach each other's files at all, they must not share a pod — see workloads.

How a file reaches the model

Your message names the file — “(attached file: scan.pdf)”, or just a sentence mentioning it. Before each model call the platform looks at what the user's own turns name, matches those against the files the store actually holds for this conversation, loads them, and appends each one to the turn that named it as a real file. The model then receives the document.

There is nothing to switch on and nothing to configure. It is inert unless a turn names a file that exists: a cheap pattern match runs first, and the store is only consulted when something filename-shaped appears — so an agent nobody uploads to never makes the call at all.

Only the user's own scopes are attached, and that is a security decision

A listing returns all four scopes and the agent may genuinely read the shared ones — but they are not auto-attached, because the text that names a file is written by an end user. An agent reaching a tenant policy document through a tool you gave it is your decision; the same document arriving because somebody guessed its name in a chat box is an enumeration oracle over your tenant's reference material. Read those with a tool, where it is a line of your code rather than a default.

The bytes are re-sent on every model call

A model request is rebuilt from the conversation each time, so an attached file lasts exactly one request — it never reaches a stored row, which is why your session storage does not grow by the size of the attachment. The other side of the same coin is the cost: a tool loop of five calls sends the file five times, and so does every later turn whose history still names it. That is what inline multi-modal costs anywhere.

There is a budget of 16 MiB of attachments per request, across all of them, with the newest turn's files attached first — so under a tight budget the file somebody just sent is the one that survives rather than the one they mentioned ten turns ago. A file that does not fit is skipped and logged, never truncated: half a PDF is not a smaller PDF, and a model handed one fails in a way that reads as the model being wrong. Anything larger than that is what a tool is for — nothing says a file small enough to store is small enough to put in a prompt.

A missing attachment never fails the run

If a file cannot be loaded the agent still has the filename in its text and still has whatever tools you gave it. That is a missing attachment, not a broken invocation, so it is logged and the run continues.

Reading one back

Downloads are a short-lived URL too, for the same reason as uploads — and a browser navigating to one needs no cross-origin configuration at all, because nothing reads the response from script. Deleting is the one operation with no signed URL: it goes through the platform with your own credential, removes every version of the file, and cannot be undone. Deleting a file in a shared scope needs an admin, as writing one does; a file in a user's or a conversation's scope can be deleted by any credential for the project.

Where to go next

The artifacts API has the upload envelope, the listing and the download route, and sessions and files in the SDK wraps them. Sessions is where a conversation's own scope comes from, and invocations is the call that carries the message naming the file.

Reading this as a model? llms.txt indexes every page, and each page has a “Copy for AI” control.