LibraryFiles Namespacev6 · Updated Report

Files Namespace

Integration Identityfiles · storage
Category
storage
Namespace
files

Store, import, read, and hand out an organization's durable file assets, keeping bytes out of model context by default.

Integration Capability9
AccessMethodSide EffectDescription
readlistFilesNo

List the organization's visible files one page at a time, newest first, together with its current usage and its resolved ceilings. The result is an envelope carrying a files array plus usedBytes, maxBytes, fileCount, and maxFiles, not a bare array. A page holds up to 50 files (20 by default): follow nextCursor until hasMore is false.

writeuploadFileYes

Store small agent-generated bytes passed inline as base64. The base64 argument is capped at 128,000 characters, which is roughly 96 KB of source bytes; anything larger belongs on getUploadLink or ingestFileFromUrl. Returns the new fileId and the stored size only.

writegetUploadLinkYes

Mint a 15-minute write-once upload URL and reserve a file id for it. The id comes back before any bytes exist, so the file is real only after the PUT succeeds; confirm with getFileMetadata rather than assuming. Hand the link to a person when your runtime cannot make HTTP requests.

writeingestFileFromUrlYes

Durably enqueue an HTTPS source for background transfer into Files, and never download the bytes yourself. Idempotent on the caller-owned UUIDv7 creationId: replaying it with identical url, filename, mimeType, and maxBytes returns the same ingestion, while changing any of those under the same creationId is refused as an intent mismatch. maxBytes is a refusal boundary you accept, not an estimate of the source size, and the per-MIME cap still clamps it.

writegetFileIngestionNo

Read the durable status of one URL ingestion: pending, running, completed, or failed, with a failureCode on failure and the canonical Files record under file once it completes. It changes nothing, yet it requires the write scope, because its output can be an unanchored writer-stash file whose visibility is coupled to write entitlement. The source URL is never returned.

writedeleteFileYes

Permanently delete one file. A file referenced by a document is refused, and the refusal names the referencing document ids; remove the reference from the document first. This is the only files method that needs the document management permission rather than the create permission.

readgetFileMetadataNo

Read one file's filename, MIME type, size, creation time, and the ids of documents that reference it. Metadata only: getFileContent is the method that returns bytes.

readgetFileContentNo

The only method that puts file bytes into the run, and only up to 96,768 source bytes. Oversized files are refused before storage is touched, never truncated. encoding defaults to auto, which decides between text and base64 from the stored MIME type plus a byte sniff, and the response always reports which one you got.

writegetReadLinkNo

Mint a 15-minute signed download URL serving the original bytes with an attachment disposition. It has no size ceiling, which makes it the path for files too large for getFileContent, but the link leaves the sandbox and outlives the run, so this deployment classifies it as a write and refuses it to a read-scoped caller even though it stores nothing.

Limitations

Files are flat and immutable: there is no rename, no move between organizations, no folders or tags, no versioning, no in-place edit, and no search over file contents. Storing a corrected version means uploading a new file and repointing the document. listFiles returns one page at a time (20 by default, at most 50): follow nextCursor until hasMore is false, and pass a filename filter (exact, prefix or contains) to find a file by name. Only getFileContent can bring bytes into the run, and only under 96,768 source bytes, with a second refusal when JSON escaping expands a compliant file past the 128 KiB provider-result ceiling; nothing partial is ever returned. uploadFile is bounded independently by the 128,000-character base64 argument limit. Per-file size is capped by MIME type, 10 MB for images and PDFs, 90 MB for video and audio, and 5 MB otherwise including text, and a plan upgrade never raises that: it raises only the organization's total bytes and file count. URL ingestion refuses anything that is not plain HTTPS: IP literals, embedded credentials, URL fragments, special-use hostnames, api.cloudflare.com, and Oddlynew's own hosts are all blocked before the transfer is enqueued, and at most two ingestions may be pending or running for one organization at a time. Deleting a file that a document references is refused rather than cascading. Filenames and MIME types are caller-supplied and stored verbatim, so treat them as untrusted third-party text.

Usage

Only ingestFileFromUrl carries a caller-owned idempotency key, its creationId, which must be a UUIDv7: mint it with ids.mintOperationIds in a separate preparation run when your host cannot generate UUIDv7 locally, and reuse it only for an exact retry of the same intent. Every other method is a plain call with no receipt, so uploadFile, getUploadLink, and getReadLink each produce a new artifact on every call: a repeated uploadFile stores a duplicate file and consumes quota twice. deleteFile is idempotent in effect but answers not-found once the row is gone. listFiles returns one page at a time (20 by default, at most 50): follow nextCursor until hasMore is false, and pass a filename filter (exact, prefix or contains) to find a file by name. Ingestion is asynchronous and getFileIngestion is the only way to learn its outcome, so poll it with a backoff rather than a tight loop, and never call a URL output durably saved until the state reads completed. Each run passes through throughput admission per target organization, so batch reads deliberately instead of iterating one file at a time.

Authorization

Two planes stack. The MCP scope comes first: mcp:read reaches only listFiles, getFileMetadata, and getFileContent, and everything else, including getReadLink and getFileIngestion, requires mcp:write. That classification is explicit rather than inferred, so a method added later fails closed until this deployment classifies it. The organization permission comes second and rides the document management vocabulary, because files are document assets: reading needs the documents read permission, writing needs the documents create permission, and deleteFile alone needs the documents manage permission. Every call resolves live authority for the named organization and asserts the organization is not fenced by its lifecycle. Visibility narrows for read-entitled callers: a caller who cannot write sees only files anchored to a document, and an unanchored workspace file answers not-found for them rather than announcing that it exists.

Composition

Documents reference files by a stable URI, the scheme oddlynew-asset: followed by the file UUID, which is what the attachment, audio, image, and html blocks store; external http and https URLs in block content are ordinary Markdown and are not asset references. Persisting a document re-extracts its full reference set and rewrites the file anchors in the same transaction, so getFileMetadata's anchoredBy is always current. Planting a reference to a file owned by another organization fails the persist loudly, while a reference to a file that no longer exists is dropped silently and the viewer renders its unavailable state. Integrations deposit into Files rather than returning bytes: elevenlabs.textToSpeech streams generated audio straight in and returns metadata, higgsfield hands back a filesHandoff whose provider URL is durable only for about seven days until you ingest it, and sevdesk.downloadVoucherDocument stores provider documents there. Read a deposited file with getFileContent when it fits, or hand a person getReadLink.

Files

What it does

Files is the organization's durable asset store. A file belongs to the organization, not to a person or a document, so every member and agent with access to that organization sees the same list. The callable namespace is files. Its central design fact is that bytes stay out of model context: eight of the nine methods return ids, metadata, links, and status, and only getFileContent puts actual content into the run, under a ceiling that refuses rather than truncates.

Every method names an organization explicitly. There is no ambient default here, so start from organizations.listMine and carry the organizationId through.

Getting bytes in: three doors, one right answer each

There are three ways to create a file, and they are not interchangeable.

uploadFile takes the bytes inline as base64. It is for content the agent itself produced, a generated CSV, a small JSON export, a rendered summary. Its contract allows 128,000 base64 characters, roughly 96 KB of source, but whatever you pass inline is written into the run's code, and that code is capped at 32 KiB, so in practice one run carries a few tens of KB. Larger content goes through getUploadLink; to turn long text into a document, follow "Long text into a document" below.

getUploadLink mints a 15-minute write-once URL and reserves a file id. If your runtime can make HTTP requests, PUT the bytes yourself; if it cannot, give the person the link and let their browser do it. The important asymmetry is that the id comes back before any bytes exist. Nothing is stored until the PUT lands, so the file is not real just because you hold its id, and getFileMetadata is how you find out.

ingestFileFromUrl is the door for bytes that already live at a public HTTPS URL, which is the normal case for connector output. Oddlynew fetches the source in the background. Never fetch such a URL yourself and re-upload it as base64: that path is bounded at 96 KB and burns the run's context on data that was never meant to pass through it.

const mine = await organizations.listMine({})
const organizationId = mine.organizations[0].organizationId

const payload = JSON.stringify({ generatedAt: new Date().toISOString(), rows: 42 })
const stored = await files.uploadFile({
  organizationId,
  filename: 'run-summary.json',
  mimeType: 'application/json',
  contentBase64: btoa(payload),
})

// stored is { fileId, sizeBytes }. Nothing else about the file comes back here.
const meta = await files.getFileMetadata({ organizationId, fileId: stored.fileId })

The example shows the flow; live server validation inside run is authoritative for exact parameters.

Importing a URL, and waiting for it properly

ingestFileFromUrl is durable and asynchronous. It returns an ingestionId and a state, and that is all: the transfer has not happened yet. getFileIngestion is the only way to learn the outcome. The state is pending, running, completed, or failed. On completed, the response carries the canonical Files record under file; on failed, it carries a failureCode naming what went wrong, such as url_refused, source_http_status, first_byte_timeout, transfer_timeout, file_too_large, capacity_unavailable, authorization_lost, or source_expired. The source URL is never echoed back.

Two parameters deserve care. creationId is a caller-owned UUIDv7 and is the idempotency key: calling again with the same creationId and identical url, filename, mimeType, and maxBytes returns the same ingestion and its current state, with no second transfer. Change any of those four under the same creationId and the call is refused as an intent mismatch, which is the system telling you that you reused a key for a different job. maxBytes is not an estimate of how large you think the source is; it is the boundary at which you want the transfer abandoned. The per-MIME cap is applied on top of it, so asking for 2 GiB of an image/png still stops at the 10 MB image limit.

// creationId is a UUIDv7 generated on your own host and kept as a literal; a host that
// cannot generate one mints it with ids.mintOperationIds in a run of its own, never in this one.
const creationId = '019f9a12-4c7e-7a31-9d55-2b6c8f0e4471'

const started = await files.ingestFileFromUrl({
  organizationId,
  creationId,
  url: output.url,
  filename: 'render.mp4',
  mimeType: 'video/mp4',
  maxBytes: 200 * 1024 * 1024,
})

const status = await files.getFileIngestion({ organizationId, ingestionId: started.ingestionId })
// Poll with a backoff while status.state is 'pending' or 'running'.
// Only status.state === 'completed' makes status.file the durable record;
// 'failed' carries status.failureCode and status.file stays null.

The example shows the flow; live server validation inside run is authoritative for exact parameters.

Generate the creationId as a UUIDv7 on your own host before the first attempt and keep the literal. Call ids.mintOperationIds only if your host cannot generate one, in a run of its own: it must be that run's only provider call, and the runtime rejects every other provider call in a run that successfully mints ids. Paste the returned value into the ingestion script as a literal, and reuse that literal only for an exact retry of the same intent.

Getting bytes out without flooding the run

getFileContent is the only method that returns content, and it is deliberately small. The ceiling is 96,768 source bytes, derived from the 128 KiB provider-result cap minus the response envelope. A file over that is refused on a metadata read, before storage is touched, and the refusal names the actual size and points at getReadLink. There is a second refusal you can hit even under the ceiling: JSON escaping can expand a compliant file past the result cap, and that too refuses rather than returning a truncated string. In both cases nothing was read and no partial content exists.

encoding defaults to auto. Text-shaped MIME types come back as a string, positively binary ones as base64, and the response's encoding field always reports which, so a run never has to guess. auto also sniffs the leading bytes: a byte-order mark or an early NUL sends the file to base64 even when it is labelled text/csv, because a UTF-16 spreadsheet export decodes to NUL-riddled text that parses cleanly into wrong data. Asking for encoding: 'text' is a contract rather than a preference: it refuses that file instead of returning it.

getReadLink is the escape hatch with no size ceiling. It mints a 15-minute signed URL that serves the original bytes with an attachment disposition, for a runtime outside the sandbox or for a person. Because that link leaves the fence and outlives the run, this deployment classifies it as a write, so a read-scoped caller is refused even though the method changes no state. That is a real dead end worth planning around: if you only hold mcp:read and the file is oversized, neither method will serve you, and a write-scoped caller has to mint the link.

Long text into a document

A text file of up to 96,768 bytes needs no splitting: read it with getFileContent and encoding: 'text' in the run that creates the document, and data from an integration goes the same way without a file. Both follow the three rules in Fetch data and create documents in the same run: return ids, never content; check every answer for codemodeResultTruncated before writing from it; and generate each creationId on your own host before the first attempt.

A document's content reaches documents.createDocument as a Markdoc string inside the run, and the run's code is capped at 32 KiB. Text larger than that, a long transcript or an export, does not go through the code. It goes through Files and is joined on the server:

  1. Split the text at paragraph boundaries into parts of about 80 KB each, under the 96,768-byte ceiling of getFileContent with room for JSON escaping.

  2. For each part, mint getUploadLink and PUT the bytes from your own runtime. A text file may hold up to 5 MB.

  3. In one run, read the parts with getFileContent and encoding: 'text', join them, and pass the result to documents.createDocument with the target typeId, its metadata, a creationId minted on your host, and writeClass: 'bulk' for an import.

  4. Once the create confirms writeConfirmed, delete the part files with deleteFile. The document holds the text itself, not a reference to them.

const parts = []
for (const fileId of partFileIds) {
  const part = await files.getFileContent({ organizationId, fileId, encoding: 'text' })
  parts.push(part.content)
}
return await documents.createDocument({
  organizationId, typeId, creationId, title, metadata,
  writeClass: 'bulk',
  draft: { markdoc: parts.join('\n\n') },
})

Write a type's long text block, such as a meeting transcript, whole in this create. Adding paragraphs to a text block later means replacing the whole block in one edit, which runs into the same 32 KiB ceiling.

What a read-only caller can see

Visibility is not uniform. A caller who holds the write entitlement sees every file in the organization. A caller who holds only read sees only files anchored to a document, which is to say assets of content they could already read. The unanchored workspace stash is a writer's surface.

The concealment is total rather than polite: getFileMetadata, getFileContent, and getReadLink all answer "File was not found" for an unanchored file, exactly as they would for a file in another organization. So a not-found on an id you believe is valid is a permission signal at least as often as it is a wrong id. getFileIngestion sits on the same reasoning, which is why a method that reads no state still needs write authority: its output can be an unanchored file.

Deleting, and why deletes get refused

deleteFile is permanent and needs the document management permission, a step above every other write here. It refuses any file a document references, and the refusal names the referencing document ids. Remove the reference from the document first, which re-materializes the anchors on persist and frees the file.

The check and the delete share one transaction with the file row locked, so there is no window where a concurrent document persist can anchor a file you are deleting. That means a refusal is authoritative at the moment you read it, and the fix is always the same: edit the document, then delete.

Two ceilings that move independently

Quota confusion is the most common wrong model here. listFiles returns an envelope, not an array: the file summaries sit under files, and the four usage numbers sit beside them at the top level, so result.files is what you iterate and result.length is undefined. Those numbers are the organization's byte quota and file-count cap, both set by its plan, reported as usedBytes against maxBytes and fileCount against maxFiles; they describe the whole organization, not the page. Separately, each individual file is capped by its MIME type: 5 MB for images, 10 MB for PDFs, 90 MB for video and audio, 5 MB otherwise including text.

These never interact. A plan upgrade raises the organization ceilings and does nothing at all for a single oversized file, and a nearly empty organization still refuses a 6 MB text file. When a write fails, read the message: a quota rejection names the used and maximum bytes or the file count, while a per-file rejection names the size and the limit for that MIME type. They call for different fixes.

For discovery, ask guide a direct question such as How do I store a file in Oddlynew?. For exact parameters, live server validation inside run is authoritative.

Oddlynew PublicSign in

Referenced by