---
title: "Files"
description: "Files are the unified content primitive — every piece of user content is a file row, with images and videos as automatic specializations."
section: "General"
group: "Concepts"
---

# Files

Files are the unified content primitive in Aeontel. Every piece of user content — documents, images, videos, datasets, anything — is a file row. There is no separate "upload" or "asset" entity: a file starts in the `uploading` state, transitions to `ready` once bytes are in R2 and the MIME type has been sniffed, or to `error` on any terminal failure.

## Storage model

Each workspace gets its own R2 bucket (`aeontel-wsp-{"{id}"}` ), created on `workspace.created`. R2 storage is **flat and keyed by file id** — every file object lives at `files/{"{fileId}"}`. Hierarchical organization lives entirely in the database via [directories](/concepts/directories) ; R2 never sees a directory path.

Multipart upload state (the R2 multipart ID, per-part ETags, total size) lives inline on the file row and is nulled out once `status = "ready"`. There is no separate bookkeeping table.

## Upload lifecycle

Uploads are a standard R2 multipart flow directly on the file entity:

1. `POST /api/files` — initiate. Server creates the file row
   in `uploading` state and returns signed PUT URLs for each
   part.
2. For each part, the client PUTs the raw bytes directly to the signed
   URL and captures the `ETag` from the response.
3. `POST /api/files/:id/parts` — sign more parts if the
   initial batch wasn't enough.
4. `POST /api/files/:id/complete` — finalize. Server completes
   the R2 multipart upload, sniffs the MIME type, and flips the row to
   `ready`.
5. `POST /api/files/:id/abort` — on any failure. Deletes the
   row and buffered R2 parts.

The CLI's `aeontel file upload` command drives this whole lifecycle from Node with a progress bar. In the browser, the platform Content page uses the same flow with parallel part uploads.

## Image and video specializations

[Images](/concepts/images) and [videos](/concepts/videos) are 1:1 side tables on `file`, keyed by file id. They are populated automatically when a file with an `image/*` or `video/*` MIME type reaches the `ready` state — an event handler emits `image.provision_requested` or `video.provision_requested`, a downstream handler provisions the asset in Cloudflare Images or Cloudflare Stream, and the side-table row is inserted. You never need to "promote" a file — upload with the right MIME type and the specialization appears.

## Operations

- `GET /api/files` — list files, filter by
  `workspace_id`, `directory_id`, or
  `mime` prefix
- `GET /api/files/:id` — retrieve file metadata
- `POST /api/files` / `/parts` /
  `/complete` / `/abort` — the multipart upload
  flow
- `PATCH /api/files/:id` — rename, move, or set `expiresAt`
- `GET /api/files/:id/download` — short-lived signed R2
  download URL
- `DELETE /api/files/:id` — delete the file (handler cleans
  up R2, CF Images, and CF Stream)
- `POST /api/files/pdf-from-url` — render a URL to PDF via
  Cloudflare Browser Rendering and store the result as a file
- `POST /api/files/screenshot-from-url` — capture a URL as a
  screenshot and store the result as a file
- `POST /api/files/markdown-from-url` — render a URL and
  extract its content as markdown, stored as a `text/markdown` file
- `POST /api/files/html-from-url` — capture the post-render
  HTML of a URL, stored as a `text/html` file
- `POST /api/files/:id/to-markdown` — convert an uploaded
  file (PDF, DOCX, PPTX, XLSX, image, HTML) to markdown via
  Cloudflare Workers AI, stored as a new `text/markdown` file

All operations are available via the CLI ( `aeontel file list/get/upload/update/download/read/delete/pdf-from-url/screenshot-from-url/markdown-from-url/html-from-url/to-markdown` ), the MCP server, and the `@aeontel/react` hooks.

## TTL and browser-rendered files

Every file row supports an optional `expiresAt` timestamp. When set, a daily cleanup worker removes the row and its R2 object once past the deadline — the standard `file.deleted` event still fires so downstream CF cleanup cascades normally.

Files generated by `pdf-from-url` / `screenshot-from-url` default to **temp**: they land at the workspace root with a 1-day TTL. Passing a `directoryId` switches to permanent placement; passing an explicit `ttlMs` (or `null` for "never") overrides either default. To promote a temp file to permanent, `PATCH /api/files/:id` with `expiresAt: null` (and optionally a new `directoryId`).

Screenshots produced via `screenshot-from-url` are plain image files — they are **not** auto-provisioned as CF Images entities. Callers that want variants can materialize an image from a permanent copy.

## Events

- `file.created` — file row inserted in
  `uploading` state
- `file.ready` — multipart upload finalized, MIME sniffed
- `file.error` — terminal failure
- `file.updated` — rename or move
- `file.deleted` — delete; payload carries `r2Key`
  , `bucketName`, and optionally `cfImageId` /
  `cfStreamUid` so the cleanup handler can cascade into CF
  Images and CF Stream.
