Skip to content
Hoody.com

Every file is a URL. Read, download, hash, and browse files across local storage and 49 backend types (Google Drive, Dropbox, S3, OneDrive, and others) through one HTTP interface.

Every Hoody container runs hoody-files, which provides that access wherever the files live.


hoody-files exposes storage as HTTP endpoints:

  • Web file manager - Visual file browser with a built-in code editor, and the main entry point for interactive file management
  • Read files - Stream content via HTTP from any mounted storage
  • Download files - With progress tracking; use the hash endpoint to compare SHA256 with an expected digest
  • Get metadata - Size, type, modification time without downloading
  • Verify hashes - SHA256 integrity checking
  • List directories - Browse with sorting, filtering, multiple formats
  • Mount 49 backend types - Google Drive, S3, Dropbox, SFTP, WebDAV, and more
  • Response formats - HTML browser, JSON API, one name per line for scripts
  • Archive preview - Inspect .tar.gz/.zip contents without extracting
  • Directory archives - Download a local directory as .zip with the ?zip parameter when archive downloads are enabled
  • Base64 encoding - Embed files in JSON or data URLs

Each link below goes to the reference page for that endpoint, with all parameters, responses, and examples.

File reading and downloading:

  • GET /api/v1/files/{path} - Read/download file content
    • Query params: backend, base64
  • GET /{path} - Alternative endpoint with HTML/JSON/simple formats
    • Query params: json, simple, content-type
  • HEAD /{path} - Get metadata without downloading
    • Returns metadata headers (file size, type, modification time)

File integrity:

Writing and moving:

Search and inspection:

Archive operations:

Remote downloads:

  • GET /{directory}?download=<url> - Fetch a URL straight into the container, without it passing through your machine; URL-encode the source URL
  • HTTPS downloads trust the container’s own certificate store as well as the standard public roots, so a URL signed by a certificate authority you have installed in the container works without any extra setting
  • GET /{directory}?downloads and GET /?download_history - In-flight and past downloads

Directory listing:

Reaching a remote without mounting it:

  • GET /{path}?type=… - One-off access to SFTP (type=ssh or type=sftp), WebDAV, FTP or S3. Set server to the host and optional port. SFTP requires user and either a base64 pass or a base64 key, with an optional base64 passphrase. WebDAV connects over HTTPS, with optional user, base64 pass and webdav_path. S3 also requires s3_bucket and s3_region; user and base64 pass carry the access key and secret when needed. Nothing is stored, so there is no backend ID and no reconnect after a restart
  • PUT /{path}?type=sftp - Upload over the same one-off path

Persistent FUSE mounts:

Pending uploads (files a mount could not upload; see Pending uploads):

WebDAV:

Backend management:

Journal:

System monitoring:

This is a summary, not the full surface: the service serves 100 distinct paths. The Files API reference is the complete list.


Open the container files URL in a browser:

https://{project}-{container}-files-1.{server}.containers.hoody.com

The interface provides:

  • Visual folder navigation - Click folders to browse
  • Built-in search - Find files quickly
  • Sortable columns - Sort by name, size, date
  • Code editor - Edit text files directly in browser with syntax highlighting
  • Upload interface - Drag-and-drop file uploads (if permitted)
  • Download manager - Fetch a URL into the current folder, with a progress bar, history, cancel and retry
  • Folder download - Download a folder as a .zip file
  • File preview - View images, PDFs, text files inline
  • Remote button - Connect an SFTP, WebDAV, S3 or FTP server and browse it as a folder; see Connect a remote folder

Add ?view to a file’s URL to open it read-only: the page has no Save, rename, move or delete. ?edit opens the editable page.

Use it for daily file management, quick edits, browsing cloud storage, reviewing logs, and editing config files, none of which require leaving the browser. The interface is the same on a phone, a tablet, and a laptop, and works with touch as well as a mouse and keyboard. A browser that already has the file manager cached picks up an updated interface within a day of a deployment, or immediately on the next new version.

The Remote button is in the toolbar on a desktop and in the actions bar on a phone. It opens a form for an SFTP, WebDAV, S3 or FTP server. Fill in the server and its credentials, optionally give the folder a name, and connect.

  • Where it appears. The folder is mounted in the service’s mount directory, by default /hoody/mounts/permanent/<name> in a container, and the file manager opens it at once. For S3, it opens in the bucket and prefix you gave, and the browser remembers them for that folder.
  • Folder names. Up to 64 ASCII letters, digits, ., _ and -, starting with a letter or digit. Left empty, the name is built from the type and the server’s host name, or from the bucket for S3 without an endpoint.
  • Connected folders. The form lists them with Open and Disconnect. Disconnect removes the folder and keeps the saved connection. The message says whether the uploads waiting on the folder were delivered; when it says delivery is incomplete or unknown, uploads may still be running, and files that could not be sent are held, see Pending uploads.
  • Removing the saved connection. Use the API: DELETE /api/v1/backends/{id}. The web UI has no button for it.
  • Credentials. They are sent in request bodies only, never in a URL.
  • When the button is absent. Remote access is off on the service, or the service’s settings keep the file manager from opening mounted folders.

The same request shape works against every mounted backend:

Terminal window
# Container files URL: https://{project}-{container}-files-1.{server}.containers.hoody.com
# Local container files
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/documents/report.pdf"
# Google Drive
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/Work/report.pdf?backend=8f3a2c1e4b5d6f7a"
# Amazon S3
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/backups/data.zip?backend=2b9d4e6a1c3f5078"
# Dropbox
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/Photos/vacation.jpg?backend=7c1e9a3b5d2f4860"

Only the backend parameter differs between them.

Connect a storage provider once:

Terminal window
# --token after the command path is this subcommand's own flag, so it is
# Google's OAuth token and it lands in the request body. A --token typed
# BEFORE the subcommand is the global one, your Hoody API bearer.
hoody files backends drive create --container "$CONTAINER_ID" \
--client-id "your-app.apps.googleusercontent.com" \
--client-secret "your-secret" \
--token '{"access_token":"ya29..."}'

Response (201 Created):

{
"success": true,
"message": "drive backend connected successfully",
"data": {
"id": "9b4d3e2f5a6c7890",
"type": "drive",
"vfs_backend_type": "drive",
"config": { "client_id": "***", "client_secret": "***", "token": "***" },
"mount_paths": []
}
}

The creation response gives the storage engine’s name for the type in vfs_backend_type, lowercase with spaces (google cloud storage for google-cloud-storage), and echoes the stored config. Only settings that are not secret come back as sent: host, port, user, username, region, endpoint, provider, bucket and path. Every other value is replaced with "***". The list and detail responses return no settings other than server and user, and use backend_type instead of vfs_backend_type, so read the field that matches the call you made.

Pass the data.id from your connect response as backend to reach Drive files; the examples below use the ID shown above:

Terminal window
# List Drive root
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/?backend=9b4d3e2f5a6c7890"
# Download Drive file
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/Documents/report.pdf?backend=9b4d3e2f5a6c7890" \
--output report.pdf

A connect proves the credentials by listing the remote’s root, within 20 seconds. If that fails, it answers 400 “Connection failed: …” and saves nothing; a backend already connected with the same type, host and account keeps its credentials. While a mount of that backend is in use, a connect with other credentials answers 409 “Cannot update credentials while a mount of this backend is in use”; delete its mounts first, the same as for PUT /api/v1/backends/{id}.

A read with backend of a path the remote does not have answers 404. A remote that does not answer within 30 seconds answers 504 BACKEND_UNREACHABLE, and one that refuses the connection, its TLS or the credentials answers 502 BACKEND_UNREACHABLE; nothing was read. Check it with GET /api/v1/backends/{id}/test. ?stat with backend returns the remote file’s metadata as JSON, as it does for local files.

The backend connection is saved across container restarts, so you connect a provider once and reuse its ID. Filesystem mounts are created separately with POST /api/v1/mounts; a mount with custom settings comes back in status error until you update it (see Service restarts).

POST /api/v1/mounts puts a connected backend at a path under the service’s mount directory, /hoody/mounts/permanent in a container. The service runs at most 100 active mounts; past that, a create returns 429 “Maximum mount count (100) reached”. Only mounts in status active count.

hoody share creates one of these mounts for a folder on your computer, so the rules below apply to it too.

  • One path holds one mount. Creating a mount at a path that another mount already uses, including a mount in status error, returns 409 with a message naming that mount; delete it first. Two spellings of one path, such as one through a symlinked folder, count as the same path.
  • A mount path inside another mount returns 400 “Mount path must be under the configured mount directory”, as does any path outside the mount directory. A path with a .. component returns 400 “Mount path must not contain ’..’ components”. A path that already holds a file or folder, such as a folder above another mount, returns 409 “Mount path already exists and is not a symlink”.
  • A mount path that is one of the service’s own directories, inside one, or above one returns 400 “Mount path is reserved by the service”.
  • If a folder on the mount path is moved or replaced while a mount is created or deleted, the change is taken back and the answer is 409 MOUNT_PATH_CHANGED “A directory on the mount path changed while the mount path was being changed; nothing was changed, retry”. If the change cannot be taken back, the answer is 500 and a DELETE keeps the mount, so it can be repeated.
  • A vfs_config that differs from the defaults runs the mount with those settings. See Mount settings.
  • From the moment a create takes a mount path until the mount or its backend is deleted, a change through the files API or WebDAV at the mount path, or at a folder above it, returns 409 MOUNT_PATH_RESERVED “This path is reserved for a mount, so nothing was changed.” This covers writes, uploads, copies, moves, renames, deletes, folder creation, permission and owner changes, touches, downloads and extractions. Files inside the mount are written as usual. After a restart, a mount takes it again when its link is put back, when its backend is connected again, or when it is repaired with an update. Until then, deleting a mount’s path through the files API removes only its link, and the mount stays registered. Remove a mount with DELETE /api/v1/mounts/{id}.
  • DELETE /api/v1/mounts/{id}, and a DELETE of its backend, remove only the link the mount made. A file or another link put at the mount path since is left in place, and the mount is removed. While a files API or WebDAV operation is writing at the mount path, a DELETE can wait up to 5 seconds, then returns 409 MOUNT_PATH_IN_USE “A files operation is changing the mount path; nothing was changed, retry” and changes nothing. If the link cannot be removed, the DELETE returns 500 and the mount stays, in status error; repeat the DELETE.
  • A create returns 409 MOUNT_PATH_IN_USE “a file operation is writing at the mount path; retry after it finishes” while a files API or WebDAV operation is writing at, inside or above the path, and 409 MOUNT_PATH_CHANGED “a directory on the mount path changed meanwhile; nothing was changed, retry” when a folder on the path changed while it was being taken. Neither registers anything. A create that runs out of time before it changes anything returns 409 MOUNT_BUSY “the create ran out of time before it changed anything; retry” with Retry-After.
  • A move between a mount and local files, or between two mounts that do not share a connection (different backends, or custom settings), fails before anything is created. The files API answers 409 FILE_MOVE_CROSSES_DEVICES “Source and destination are on different filesystems; a move cannot span them”, and WebDAV MOVE answers 409 with the same message. Copy the file, then delete the original.
  • The backend query parameter is accepted on GET, on PUT except append, on DELETE and on POST with mkdir. Any other files request that carries it, including ?move_to=, ?copy_to= and PATCH, returns 400 “The backend parameter is only supported for reading, uploading and deleting files and for creating directories”. On a service with remote backends turned off, a request it is accepted on returns 403 “Remote backends are disabled”. Some GET operations stay local only and return 400 with their own message: content search, file search, line ranges and path resolution (for example “grep not supported with remote backends”), and history queries (“Journal queries are not supported for remote backends”).

DELETE /api/v1/mounts/{id} takes uploads, which says what becomes of what was written on the mount and is not uploaded yet:

uploadsWhat happens
keep (default)A mount with custom settings goes on uploading in the background after the answer. It stops when nothing is open and nothing is left to upload, after at most 24 hours, or sooner if its files cannot be checked for about a minute or its oldest unsent file is 24 hours old and uploads are failing. Files still open when it stops stop working; unsent files are kept on the server and are not uploaded. A mount on the default settings leaves its uploads to its backend’s shared connection
waitThe same, and the answer waits up to wait_seconds (1 to 600, default 120). uploads.delivered is true when nothing was left open or unsent; otherwise it is false, with what was still open and not uploaded, and the uploads go on as with keep
discardWhat is not uploaded yet is deleted and never uploaded, together with the mount’s uploads already kept on the server, and files still open on the mount stop working. uploads.discarded says how many files that was. If stopping or deleting could not be confirmed, it is null: some uploads may continue or files may remain, even though the mount is removed; repeat the DELETE with uploads=discard. It also works on a mount that is already removed, and then answers 200 without mount_path. A mount on the default settings refuses it with 409 UPLOADS_DISCARD_SHARED

DELETE /api/v1/backends/{id} removes the backend’s mounts first and takes uploads=keep (the default, answered with 204) or uploads=discard. With discard it deletes what any of its mounts has not uploaded, including mounts removed earlier, and the backend’s uploads kept on the server, then answers 200 with uploads.discarded. When that could not all be deleted, uploads.discarded is null and the backend is kept. Retry the DELETE after resolving the cause. Uploads on a shared connection that another backend has used are kept to protect that backend’s files, so repeating the DELETE alone will not discard them; delete them through the pending uploads API, then retry. On a backend that is already removed, uploads=discard deletes what it left and answers 200 with removed: true. It is refused with 409 UPLOADS_DISCARD_SHARED while a mount of the backend uses the default settings; remove those mounts first. A backend has no wait: remove its mounts with uploads=wait first.

Any other uploads value, or wait_seconds without uploads=wait, answers 400 INVALID_PARAMETER and removes nothing. A service that takes uploads lists mount_delete_uploads in the features of GET /api/v1/version; an older one ignores it and keeps the uploads.

A mount’s vfs_config controls caching and write behaviour at its path. You can set it when you create the mount and change it later with PATCH /api/v1/mounts/{id}, without deleting the mount or changing its path.

FieldDefaultAccepted values
cache_modewritesoff, minimal, writes or full, in lowercase
cache_max_size10GBytes, or a size such as 512M or 20G. 0 means no limit; otherwise 1 MiB to 9007199254740991 bytes
cache_max_age1hSeconds, or a duration such as 30m or 1h30m. At most 30 days
read_chunk_size128MBytes, or a size. 1 MiB to 1 GiB
write_back5sHow long a closed file waits before it is uploaded. Seconds, or a duration. At most 1 hour
dir_cache_time5mHow long directory listings are cached. Seconds, or a duration. At most 30 days
poll_interval1mHow often the backend is checked for changes. Seconds, or a duration. At most 30 days
read_onlyfalsetrue or false. true makes the mount path refuse writes
timeoutUnset (5m)How long a transfer may stay idle before it fails. Seconds, or a duration; 1 to 600 seconds
contimeoutUnset (1m)How long connecting may take before it fails. Seconds, or a duration; 1 to 120 seconds
low_level_retriesUnset (10)How many times a failed backend request is retried. A whole number from 0 to 10

Sizes take an optional K, M, G or T suffix in either case, in powers of 1024, with or without a trailing B; B alone means bytes. Durations combine h, m and s parts in either case, and a number without a unit counts as seconds. There is no d unit: write 30 days as 720h. Numbers must be whole. Four fields also accept a longer spelling: vfs_cache_max_size, vfs_cache_max_age, vfs_read_chunk_size and vfs_write_back. Sending both spellings of one field is a 400. GET reports sizes in bytes and durations in seconds, under the short names.

allow_other and allow_non_empty are always false. GET includes them, and a request may send them as false, so the vfs_config object that GET returns can be sent back unchanged. Sending either as true is a 400.

A mount whose settings all equal the defaults uses the backend’s shared connection and works on every backend type. Any other value, called custom settings on this page, gives the mount its own connection and cache, and is accepted only on these types:

azureblob, azurefiles, b2, drive, dropbox, fichier, filescom, ftp, gofile, google-cloud-storage, google-photos, hdfs, http, imagekit, internetarchive, koofr, mega, netstorage, opendrive, oracleobjectstorage, pcloud, pixeldrain, qingstor, quatrix, s3, seafile, sftp, sia, smb, swift and webdav.

On any other type, such as box, onedrive or protondrive, custom settings return 400 VFS_CONFIG_UNSUPPORTED_BACKEND: a create makes no mount, and a PATCH leaves the mount with its current settings.

The service runs at most 100 mounts with custom settings, counting previous settings that are still in use after an update or not yet confirmed stopped, and uploads still running from before a restart. Past that, a create or update that needs custom settings returns 429 DEDICATED_VFS_LIMIT, and the response’s retiring field says how many of those are still in use. Files that a mount’s upload left unsent when it stopped, for example when the container stops or the upload crashes, are kept on the server as pending uploads. They are not uploaded on their own; you list, download, deliver or discard them through the API, see Pending uploads. For a mount with custom settings, its pending_uploads field gives the count and bytes of unsent files recorded for that mount, such as uploads still finishing from its previous settings and files held after an upload stopped. It is not a live count of the mount’s current write backlog, so a count of 0 does not show that recent writes have reached remote storage. Files left unsent on the default settings are not counted on any mount. Kept files count toward a 20 GiB limit: while they take more than that, a create or update that needs custom settings returns 507 PENDING_UPLOADS_LIMIT. Delivering or discarding pending uploads lowers the held total. Mounts on the default settings are unaffected by either limit.

PATCH /api/v1/mounts/{id} takes a body of {"vfs_config": {...}} with the fields to change. It is a partial update: fields you omit keep their current value. Sending a default value removes an override for the caching fields and read_only. timeout, contimeout and low_level_retries stay explicit overrides even when set to 5m, 1m and 10; they cannot be unset with a partial update, so recreate the mount without them to leave them unset. The body may also carry auto_deliver, which is saved with the mount and still has no effect: its default is true, and kept files are not uploaded automatically whatever its value. Deliver them with the pending uploads API. A body with neither field, or with vfs_config as {}, returns 400 VFS_PATCH_EMPTY, and any other top-level field returns 400 VFS_FIELD_UNKNOWN.

Terminal window
hoody files mounts update mount_550e8400e29b41d4a716446655440000 \
--container "$CONTAINER_ID" \
--vfs-config-cache-mode full \
--vfs-config-cache-max-size 20G

Response (200 OK):

{
"success": true,
"message": "Mount updated",
"remounted": true,
"data": {
"id": "mount_550e8400e29b41d4a716446655440000",
"status": "active",
"vfs_config": { "cache_mode": "full", "cache_max_size": 21474836480, "...": "..." },
"...": "..."
}
}

data is the updated mount, without the live uploads field that GET /api/v1/mounts/{id} adds; use that GET to check its current upload state. An update that changes nothing, on an active mount whose settings are serving, returns 200 with "message": "No changes" and "remounted": false, and restarts nothing, so retrying an update that already succeeded gets 200. A change to vfs_config restarts the mount with the new settings; a change to auto_deliver alone does not. An update also completes if the client disconnects before the response arrives; retry it to read the result. An update can take up to about 25 seconds to answer, so set your client timeout above that.

  • Files written and closed through the mount must finish uploading before the switch. The update waits up to 10 seconds for them. A mount on the default settings also waits for the closed files of the backend’s other default-settings mounts. Files still open are not waited for. If files remain, nothing changes and the update returns 409 MOUNT_UPDATE_IN_PROGRESS with messages such as “N files waiting for the upload delay of W s; retry after it”, or “N files cannot be uploaded (last error: …); fix the backend, or delete the mount”.
  • An update also waits for a delivery of this mount’s held files, or shared held files of its backend. If the delivery still runs after the wait, the update returns 409 MOUNT_UPDATE_IN_PROGRESS “a delivery of this mount’s held files is running; retry after it finishes”. ?switch=immediate does not skip this wait.
  • With a write_back longer than about 10 seconds, updates are refused this way until that delay has passed since the last file closed through the mount. To switch without waiting, add ?switch=immediate. The update then does not wait for the previous settings’ closed files, and the response carries previous_uploads_pending, the number of closed files it saw that the previous custom settings still had to upload; it is 0 when the mount was on the default settings. If the service cannot count those files in time, the update returns 409 MOUNT_BUSY and changes nothing; retry it. Those files upload on their own schedule, so a closed file uploaded late can overwrite a newer write made through the new settings, and any still unsent when that upload stops, for example because the container stops, are kept on the server and not uploaded. Where you can, let uploads finish and update without ?switch=immediate. switch takes wait, the default, or immediate; any other value is a 400.
  • The mount path switches to the new settings in one step. Files opened after the switch use the new settings. A process whose working directory is inside the mount must cd into it again.
  • Files already open keep the previous settings until they are closed. When the previous settings were custom, that holds until they are shut down: at the latest 24 hours after the switch, or sooner if their files cannot be checked for about a minute, or their oldest unsent file is 24 hours old and their uploads are failing. Files still open then stop working. When they were the defaults and no other mount uses the backend’s shared connection, files still open may stop working within about two minutes of the switch.
  • When the previous settings were custom, a file open for writing across the switch is uploaded with them when it is closed. If the same file is also written through the new settings in the meantime, the upload that finishes last wins. The same holds for two mounts of one backend when at least one of them has custom settings.
  • read_only applies to the mount path only. On writable backend types, file operations with ?backend= can still write. The http backend is always read-only, regardless of this setting; its uploads, deletes and directory creation return 403 BACKEND_READ_ONLY.
  • A mount with custom settings keeps its own cache. Changes made through another mount of the same backend, or through ?backend=, appear on it after dir_cache_time or the next poll. A mount set back to all default settings shares the backend’s cache again, and such changes then appear on it immediately.
  • A mount in status error can be repaired with a PATCH that includes vfs_config, even with the settings it already has, while its backend is connected. A mount listed as error because its saved settings were refused can be repaired with an update that corrects them. If the update is still refused, the mount stays as it was. A mount whose settings stopped serving shows the message “the mount’s process exited; repeat the update to restart it”, and repeating the update restarts it without waiting for its own uploads.
  • A mount on the default settings comes back active at once if its backend reconnects. Otherwise it is in status error with the message “backend unavailable” until the backend is connected again.
  • A mount with custom settings comes back in status error with the message “the mount has settings of its own and was not started after the restart; update it to start it”. Send a PATCH that includes its vfs_config to start it.
  • Uploads that were running keep running across the restart, and files open across it stay open, unless the service cannot identify the old upload’s mount.
  • After a restart, a backend that uploads from before the restart may still be writing to is kept unconnected, and its mounts show status error with the message “backend unavailable”. It does not reconnect on its own. While it is kept unconnected, creating a mount on it returns 404 BACKEND_NOT_FOUND Backend not found: <id>, files requests with ?backend= on it and testing it return 404 Backend not found: <id>, and an update of one of its mounts returns 409 MOUNT_BACKEND_UNAVAILABLE. Connecting it again with POST /api/v1/backends/{type} answers 409 with Retry-After: 5 while those uploads are still running, with “N files from before the restart are still uploading (last error: …); retry after they finish”, or “uploads from before the restart could not be checked yet; retry”.
  • Those uploads end on their own: about a second after their last file is sent, when the old upload ends, or within about a minute if the service cannot check its files. The old upload is also stopped when its oldest unsent file is 24 hours old and its uploads are failing. After 24 hours the old upload is stopped too, even while a program has a file open through it. Files it could not send are kept on the server and not uploaded. If the old upload is still running after a service restart, the block stays until one of these happens.
  • Then connect the backend again with its credentials. Mounts on the default settings come back active; update each mount with settings of its own with a PATCH that includes its vfs_config.

Error responses have the form {"success": false, "error": "<message>", "code": "<CODE>"}. Several cases share a status, so read code to tell them apart.

StatusCodeMeaning
400VFS_PATCH_EMPTYThe body sets neither vfs_config nor auto_deliver, or sends vfs_config as {}
400VFS_FIELD_UNKNOWNThe body or vfs_config names an unknown field
400VFS_FIELD_NULLA field is null
400VFS_FIELD_NOT_SETTABLEallow_other or allow_non_empty is true
400VFS_CONFIG_INVALIDA value has the wrong type or is outside the limits, or a field is given twice
400VFS_CONFIG_UNSUPPORTED_BACKENDCustom settings on a backend type that does not support them
400INVALID_JSONThe body is not valid JSON, or not a JSON object
400INVALID_PARAMETERswitch is not wait or immediate
400MOUNT_PATH_INVALIDRepairing a saved mount whose path is not valid under the mount directory
400MOUNT_PATH_RESERVEDThe mount is at a path the service keeps for its own files. Delete the mount and create it at another path
403noneRemote mounts are turned off on this service
404MOUNT_NOT_FOUNDNo mount has this ID
409MOUNT_UPDATE_IN_PROGRESSClosed files have not finished uploading within 10 seconds, a delivery of the mount’s held files (or shared held files of its backend) is running, or files from before a service restart are still uploading. Follow the message: retry after the stated delay, fix the backend, or, for uploads from before a restart, retry later. Nothing has changed
409MOUNT_BUSYAnother change of this backend is running, the mount is still starting, the mount changed while the update waited, its closed files, or files from before a restart, could not all be checked in time, or the update ran out of time. Nothing has changed; retry after the Retry-After seconds. A mount that is being removed answers MOUNT_BUSY without the header
409MOUNT_BACKEND_UNAVAILABLEThe mount’s backend is not connected. Connect it again, then retry
409MOUNT_PATH_IN_USERepairing a saved mount whose path another mount now uses, or where a file operation is writing
409MOUNT_PATH_CHANGEDSomething else replaced the mount’s link at its path, or a folder on the path changed during the switch: the mount goes to status error with that reason; delete it and create it again. When the message ends “nothing was changed, retry”, the mount is unchanged; only retry
413PAYLOAD_TOO_LARGEThe body is larger than 64 KiB
429DEDICATED_VFS_LIMITThe service runs 100 mounts with custom settings. retiring says how many are previous settings, or uploads from before a restart, still in use or not yet confirmed stopped
500MOUNT_PERSIST_FAILEDThe new settings could not be saved, and the mount keeps its previous settings
500MOUNT_SWAP_FAILEDThe new settings are saved but could not be switched in. The mount goes to status error with the message “the new settings are saved but could not be switched in; repeat the update”, and its previous settings keep serving until you repeat the update
502MOUNT_REMOUNT_FAILEDThe mount could not start with the new settings, for example because the backend refused the connection. The mount keeps its previous settings
503MOUNT_STORAGE_UNAVAILABLEThe service’s storage check fails, with Retry-After: 30. When the check fails on one of the service’s directories (the state or the run directory), the message names it and why; correct that directory, then retry. See Storage check
507PENDING_UPLOADS_LIMITUnsent files take more than 20 GiB, so custom settings are refused. Deliver or discard pending uploads to free space

A pending upload is the group of files one mount session could not upload, kept on the server. Each group has an id, a state, a count of files per reason, and deliverable, which says whether a delivery with the group’s saved connection settings can start now:

  • uploading: files that are still being uploaded on their own, by uploads that kept running through a restart or that belong to a mount that was removed or changed.
  • pending: files held because their upload stopped before they were sent (a crash, a reboot or shutdown, or a stop request). They may be incomplete or out of date.
  • delivering: a delivery you started is running.

Nothing expires: a group stays until every file is delivered or you discard it. All routes need remote access enabled; without it they answer 403.

RouteWhat it doesAnswer
GET /api/v1/pending-uploadsList the groups. ?backend_id= limits it to one backend200
GET /api/v1/pending-uploads/{id}/filesList the files, sorted by path. limit is 1 to 1000 (default 1000); pass next_cursor back as cursor for the next page, and stop when it is absent200
GET /api/v1/pending-uploads/{id}/fileDownload the held copy of one file, named with path or path_b64 as listed (exactly one). Takes Range and conditional requests200, 206, 304, 412, 416
POST /api/v1/pending-uploads/{id}/deliverUpload held files202
POST /api/v1/pending-uploads/{id}/stopStop an uploading or delivering group; its unsent files are held as pending200
DELETE /api/v1/pending-uploads/{id}Delete every held file of the group204

A file marked complete: false may be missing ranges, which read as zeros. It can only be downloaded or discarded; delivery skips it.

Terminal window
# List what is held
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/pending-uploads"
# Upload every complete file of one group with its saved connection settings
curl -X POST "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/pending-uploads/{id}/deliver"

Delivering. The body is optional and at most 4 MiB. Without paths or paths_b64, every complete file is delivered; with them, only the named files (paths takes listed paths, paths_b64 listed path_b64 values, and they can be combined). backend_id delivers to that backend with its current settings, and they become the group’s saved settings once the upload starts. If a restart or an error cuts that switch short, the group lists the backends the saved settings may now belong to in unsettled_backend_ids and shows deliverable: false. That is why a delivery without backend_id is refused with 409 PENDING_UPLOAD_NOT_DELIVERABLE until a delivery with backend_id installs that backend’s settings. Delivery overwrites newer versions of the same files on the backend, so check with the owner of the data first. Each file leaves the group as it lands; when the delivery ends, the group goes back to pending with what was not delivered, and last_error says why it ended early.

While a delivery to a backend runs, that backend cannot be updated or connected again: PUT /api/v1/backends/{id} and a repeat connect return 409. To stop a delivery and keep its unsent files, use POST /api/v1/pending-uploads/{id}/stop. Deleting a backend with ?uploads=discard stops the deliveries to it, and the other backends’ files they carried stay held; that backend’s own pending uploads are deleted, even while they are being delivered elsewhere.

Main errors:

StatusCodeMeaning
400INVALID_PATH, INVALID_PARAMETER, INVALID_JSONA name is not one of the group’s files, a parameter is malformed, or the body is not valid
403REMOTE_DISABLEDRemote access is off
404PENDING_UPLOAD_NOT_FOUND, PENDING_UPLOAD_FILE_NOT_FOUND, BACKEND_NOT_FOUNDNo such group, held file or backend. A group whose files were all delivered or discarded is gone
409PENDING_UPLOAD_BUSYThe group is uploading or being delivered. Stop it or wait. Also returned by stop on a pending group
409PENDING_UPLOAD_NOT_DELIVERABLEThe saved settings are no longer accepted, or a cut-short delivery left them unsettled (unsettled_backend_ids); deliver with backend_id. With backend_id, that backend’s settings are not accepted for a delivery.
409PENDING_UPLOAD_FILE_INCOMPLETEA named file is incomplete (complete: false); nothing was delivered. Leave it out, or download or discard it
413PAYLOAD_TOO_LARGEThe body is over 4 MiB
429DEDICATED_VFS_LIMITThe limit of mounts with custom settings is reached; a delivery counts against it
500PENDING_UPLOAD_PERSIST_FAILEDThe change could not be recorded. A failed discard may already have deleted some of the held files. Retry

When held files exceed 20 GiB, creating or updating a mount with custom settings returns 507 PENDING_UPLOADS_LIMIT. Delivering or discarding pending uploads frees space.

A stored record of held files that the service cannot read, or that is damaged, is not a pending upload: what it holds is unknown. GET /api/v1/pending-uploads/unreadable lists them, each with an id, a reason (unreadable or damaged) and, when the service can tell, its size in bytes. An unreadable record may read again later; a damaged one will not.

While such a record exists, no mount or backend DELETE can confirm its uploads (see Removing a mount): ?uploads=wait answers delivered: false and ?uploads=discard answers discarded: null, and a backend is then kept. DELETE /api/v1/pending-uploads/unreadable/{id} deletes the record with every file it kept and answers 204. This cannot be undone, and the files may be the only copy of changes that never reached the backend; nothing on the backend changes. A record that reads again is a pending upload, deleted with DELETE /api/v1/pending-uploads/{id}; asking for it here answers 404 PENDING_UPLOAD_NOT_FOUND. 409 PENDING_UPLOAD_BUSY means an upload process still running uses the record, and 500 PENDING_UPLOAD_PERSIST_FAILED means its files could not all be deleted, so it is kept; retry.

The service checks that its own storage is private before it runs any mount. While the check fails, mounts are refused and files are not:

  • Creating a mount; GET, PATCH and DELETE of a mount; GET, PUT and DELETE of a backend; connecting and testing a backend; files requests with backend; and one-off remote access with type return 503 MOUNT_STORAGE_UNAVAILABLE “Mounts are unavailable until the server’s storage check passes; retry later.” with Retry-After: 30. They are refused before the ID is looked up, so a known ID and an unknown one get the same answer.
  • When the check fails on one of the service’s directories, the message names it: the service’s state directory, /hoody/secrets/hoody-files/vfs, or its run directory, /run/hoody-files/vfs, and the reason, for example ancestor "/hoody/secrets" has mode 775; it must not be group- or other-writable. It ends with the remedy: make every directory above it owned by root and not writable by group or others, and the directory itself owned by the service’s user with mode 0700.
  • The mount list and the backend list still answer, and mounts keep their settings. When the service starts while the check fails, the mount list shows each mount on the default settings in status error with the message “mounts are unavailable until the server’s storage check passes”.
  • Every other file request is served as usual.
  • The check runs again at most every 30 seconds. Once the storage is repaired, a retried request is served without a restart.
  • A write, copy, move, rename, permission change or delete of a path that another operation is using, or of a folder above it or something inside it, returns 409 “This path is in use by another operation; retry.” and changes nothing. The WebDAV methods PUT (including ?touch), DELETE, MKCOL, COPY and MOVE answer 423 Locked instead.
  • Two changes to the content or attributes of one file do not conflict: two uploads, appends, truncations, permission or owner changes, or touches of the same file each run. Replacing the same content or setting the same attribute leaves the last value applied, and two successful appends keep both bodies.
  • If the path changes between the check and the operation, the answer is 409 “The path changed while the operation ran; retry.”
  • A copy whose source fails or that is cancelled before it writes leaves the destination as it was. A copy whose write fails (a full disk, for example) may leave it partial, as an upload can.
  • An append’s body is received in full before the file changes. An append that is refused, cut off or cancelled before it writes leaves an existing file unchanged. A cancelled append to a file that did not exist may leave it created and empty. An append whose write fails (a full disk, for example) may leave part of its text in the file.
  • Text another program appends to the file at the same moment is kept.

The service keeps its own control and cache data in private locations. Requests for them are refused as if they did not exist, however they are reached. The contents of attached mounts stay available.

  • This covers /proc, everything inside the folders where the services keep their credentials and keys, and the service’s own data, including its file history, thumbnail cache and download and extraction records. Every route answers 404 for them and anything below them, directly, through a symlink or through /proc, and listings leave them out.
  • The credentials and keys folders themselves, and the folders above the protected locations, can be read. Deleting, renaming, replacing or changing one returns 409 “Holds or contains service storage: this path cannot be created, deleted, renamed, replaced or changed”. A folder above them that does not exist yet can still be created.
  • Journal history stays available through GET /api/v1/journal and the file history reads, and thumbnails through the thumbnail route.

Pick the format that suits the caller:

Terminal window
# Interactive file browser
open "https://{project}-{container}-files-1.{server}.containers.hoody.com/documents/"

Interactive file browser with visual navigation, search, sortable columns, and upload interface (if permitted).

Check a download against the hash the service reports:

Terminal window
# Container files URL
FILES_URL="https://{project}-{container}-files-1.{server}.containers.hoody.com"
# 1. Get expected hash: {"hash": "<hex>", "algorithm": "sha256"}
expected=$(curl -s "$FILES_URL/api/v1/files/large-file.bin?hash" | jq -r '.hash')
# 2. Download file
curl "$FILES_URL/api/v1/files/large-file.bin" -o large-file.bin
# 3. Verify
actual=$(sha256sum large-file.bin | awk '{print $1}')
if [ "$expected" = "$actual" ]; then
echo "✓ Download verified"
else
echo "✗ Download corrupted"
fi

?hash on the /api/v1/files/ route returns JSON. On the path route, GET /large-file.bin?hash returns the bare hex digest instead.

This matters most for:

  • Production deployments
  • Backup verification
  • Large file transfers
  • Compliance requirements

Preview an archive without extracting it:

Terminal window
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/backup.tar.gz?preview"
# Lists contents without downloading full archive

Base64-encode a file for embedding:

Terminal window
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/config.json?base64"
# Returns: eyJrZXkiOiJ2YWx1ZSJ9

The path route returns the encoded text and accepts files up to 100 MiB. GET /api/v1/files/{path}?base64 returns JSON with base64 and size and accepts files up to 50 MiB, as does a file on a remote backend. A larger file returns 413.

Override the content type:

Terminal window
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/data.txt?content-type=text/csv"
# Returns the file with Content-Type: text/csv, as an attachment

A content type set this way is always sent as an attachment, so a browser downloads the file rather than displaying it. A value that is not a valid content type is ignored.

Download a whole directory as a zip:

Terminal window
# Download entire directory as .zip archive
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/documents/?zip" \
-o documents.zip

A folder on a remote backend can be zipped through its mount path, or through this path form with one-off ?type= access. GET /api/v1/files/{path}?zip&backend=<id> answers 501.

GET /api/v1/files/grep/{path} searches the files under a folder for a regular expression and returns matching lines with their file path and line number. Long match and context lines are shortened to at most 4,096 UTF-8 bytes, including a trailing ...; this shortening alone does not set truncated. By default a search returns at most 50 matches per file and 500 in total. max_count raises the first up to 1,000 and max_matches the second up to 10,000; larger values count as those maximums. A search also skips files over 10 MiB (max_filesize, up to 100 MiB), descends at most 50 folder levels (max_depth, up to 100) and stops after 30 seconds (timeout, up to 120). truncated: true means the search reached a limit or could not finish; it does not prove that more matches exist. Searches also stop after 50,000 searched files or when collected match and context data reaches a 32 MiB budget. These two limits cannot be raised. Add glob to limit the search to some files:

Terminal window
hoody files grep /workspace --pattern 'TODO' --glob '*.{ts,tsx}' -c <containerId> -o json

The same search works on the file route: GET /api/v1/files/workspace?grep=TODO&glob=*.go.

The glob filter takes .gitignore-style patterns:

PatternSearches
*.goFiles named *.go at any depth
*.{ts,tsx}Files ending in .ts or .tsx
src/**/*.ts.ts files anywhere under src/, including src/ itself
src/*.ts.ts files directly in src/; * does not cross /
/main.gomain.go at the top of the searched folder only
!*_test.goEvery file except test files
!vendor/Every file outside vendor/ folders

A pattern that contains a / other than a trailing one is matched against the path relative to the folder you search, not the container root: searching workspace with glob=src/*.ts finds workspace/src/app.ts. Matching is case-sensitive, also when the content search uses ignore_case=true. A request takes one filter; repeating glob returns 400, and so does a pattern that cannot be parsed or is only spaces. A pattern starting with # has to be escaped as \#.

The filter only narrows the search. Files listed in .ignore files, or in .gitignore files inside a git repository, stay out even when the filter names them, and so do files and folders whose names start with . unless an ignore file re-includes them with a !name line. Two parameters bring them back:

  • no_ignore=true searches files that .gitignore, .ignore, and git’s exclude files would skip.
  • hidden=true searches names that start with .. .git folders inside the searched folder are still skipped.
Terminal window
curl --get "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/grep/workspace" \
--data-urlencode 'pattern=node-version' \
--data-urlencode 'glob=.github/**/*.yml' \
--data-urlencode 'hidden=true'

Both parameters also apply to file search with GET /api/v1/files/glob/{path}. If an account can only see a folder because it has access to something inside it, hidden=true on that folder returns 403.


Add ?chmod= to a file upload to set its permission bits in the same request:

Terminal window
curl -X PUT "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/secrets/key.pem?chmod=600" \
--data-binary @key.pem
# {"success": true, ..., "mode": "600"}
  • The file ends with exactly these bits, and the response carries them as mode in canonical octal. 644, 0600, 0o755 and 000 are accepted.
  • A new file is never created with broader bits than the ones you asked for.
  • A file being replaced keeps its inode and owner. It is first narrowed to what both its current mode and the requested one allow. From that point, its old content is readable only under bits both modes allow, and the new content appears under the requested mode.
  • A program that already had the file open before that point keeps its access.
  • Uploads, appends and permission changes made through the file service, by any name of the file, wait for each other. Programs that write the file directly are not covered.
  • Refused with 400, before the body is read and with nothing changed: a value that is not octal permission bits; setuid, setgid or sticky bits; chmod together with backend or append; a path that is not a regular file.
  • 403 when the service does not allow permission changes.

Older file services ignore ?chmod. Check that GET /api/v1/version lists put_chmod in features before relying on it, and check the mode in the response.


The file service runs as root inside the container. By default, the local files and folders it creates are owned by your container user (user), not root. Files and folders created through ?backend= use the remote backend’s ownership rules. The interactive shell and your processes can therefore work with what the file manager makes, subject to the file’s permission bits (archive extraction restores ordinary file permission bits where available, stripping setuid, setgid and sticky bits; new directories use 755, subject to the service’s umask, rather than their archived permissions).

This applies to every operation that creates a new inode: uploads, new folders (?mkdir), touch, appends to a new file, archive extraction (every extracted entry), copies, downloads (?download_from), and any parent directories created along the way. Existing files keep their owner: overwriting, appending to, or moving an existing file never changes who owns it.

Service start-up settings (flags and environment variables of the file service, set by whoever starts it):

FlagEnvDefaultMeaning
--default-create-owner <spec>HOODY_FILE_MANAGER_DEFAULT_CREATE_OWNERuserOwner for newly-created inodes. spec is user, user:group, uid, uid:gid, or none/off to disable (inherit the process owner = root).
--allowed-create-owners <csv>HOODY_FILE_MANAGER_ALLOWED_CREATE_OWNERS(empty)Owners a client owner= override may request (the default owner is always allowed). Resolved per request.

Ownership is fail-closed: when the feature is active the service verifies it can change ownership at startup, and if a per-operation ownership change unexpectedly fails the request returns 500 and the partially-created file/dir is rolled back: a created file is never silently left owned by root.


The journal records mutations made through the service’s local file endpoints. Each time a local file is created, written, appended, deleted, moved, copied, or has its permissions changed, the journal captures the event, and for eligible files a content-addressable blob snapshot of the file at that moment. You can therefore read a file as it existed at a past revision, at a point in time, or compute diffs between two versions.

The journal is best-effort: file operations always succeed even if journaling encounters an error. Operations that use ?backend= and changes made outside the service are not journaled.

Two start-up settings control it. --journal-path names the journal’s storage folder, and recording runs whenever it is set. --allow-journal opens the routes that read the journal: GET /api/v1/journal, its stats and flush, and the file history reads. A Hoody container sets both, so the journal is on wherever this page applies. On a deployment of your own, the journal routes answer 403 “Journal query access not enabled. Use —allow-journal flag.” without --allow-journal, and 404 “Journal is not enabled” without a journal path. -A does not include --allow-journal.

Five deliberate limits decide what history you actually get:

  • Excluded paths. .git is always excluded. Folders with common development and build names are excluded too, at any depth, because journal-ignore-dev-dirs defaults to on. The list includes node_modules, .next, .venv, venv, env, target, vendor, deps, obj, _build, dist, build, out, coverage, .cache and .svn, so a folder of your own named build or out gets no history either. Start the service with --no-journal-ignore-dev-dirs if you need them tracked.
  • Extraction and URL downloads. Files written by ?extract and by ?download or ?download_from are not recorded.
  • Large files. Files above journal-max-file-size (default 2 MiB) are recorded as an event with size and hash, but with no content snapshot.
  • Binary files. Binary content gets the same treatment: the event and hash are stored, the content is not. Both cases still show the change in history; they cannot be diffed or restored from.
  • Retention. Entries are pruned past journal-max-days (default 90) and when total journal storage exceeds journal-max-storage (default 2 GiB); over budget, new entries can be dropped as well.

The journal query (GET /api/v1/journal) and file history (?history) never return records of the service’s private data, so a page can hold fewer entries than limit, or none, while has_more is true. Keep paging with next_after_id, passed as after_id, until has_more is false. A path filter that names the service’s private data answers 404, as if it did not exist, and so does an old-version read (?at, ?revision or ?diff) of a file whose path at that version was the service’s private data. When the service cannot finish checking a request’s records in time, for example because a mount is not responding, the answer is 503 “Storage check timed out; retry” with Retry-After: 5, never a shortened page or a 404.

Browse the retained revisions of a file. Follow next_after_id while has_more is true, and check complete: false means the history has detected gaps or could not be fully reconstructed. Finishing pagination does not make that history complete:

Terminal window
hoody files get src/app.ts --history --limit 50

Read a stored revision, or the latest retained version at or before a timestamp. Timestamp reads reflect recorded history; they cannot account for missing events or changes made outside the service. A missing revision returns 404, a recorded deletion or move away returns 410, and unavailable snapshot content returns 422:

Terminal window
# By revision number
hoody files get src/app.ts --revision 3
# By timestamp
hoody files get src/app.ts --at "2026-03-19T14:30:00Z"

Compute a unified diff between two retained versions with stored text snapshots. Diff output is limited to journal-max-file-size (default 2 MiB); a shortened diff still returns 200, with X-Truncated: true:

Terminal window
hoody files get src/app.ts --diff --from-seq 1 --to-seq 3

Search across all mutations in your container and monitor journal health:

Terminal window
# Query the first 20 retained write events under src/
hoody files journal list --path src/ --op write --limit 20
# View journal storage statistics
hoody files journal stats

Different tools for different storage:
- AWS CLI for S3
- Google Drive SDK
- Dropbox API
- SFTP client
- WebDAV client
Each with different authentication, different SDKs, different patterns.

That means:

  • Multiple tools to learn
  • Different APIs per provider
  • Complex authentication flows
  • No unified interface
  • Awkward access for AI, which needs a provider-specific SDK for each one
One HTTP API for everything:
https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/{path}?backend={backend_id}

That gives you:

  • One interface for 49 backend types
  • Provider credentials configured once per connected backend; a persistent filesystem mount is optional
  • Same HTTP patterns everywhere
  • Reachable by any AI that can make a standard HTTP request
  • Observable (HTTP requests logged when access logging is enabled)
  • Embeddable (file browsers in iframes)
  • Script-friendly (simple text format)

A phone browser reaches the same files:

// From mobile browser
await fetch('https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/documents/contract.pdf?backend=8f3a2c1e4b5d6f7a')
.then(r => r.blob())
.then(blob => {
// View PDF on phone
});

The device needs no Google Drive app, no Dropbox app, and no S3 client, only the ability to make an HTTP request.


Read files from several providers in one pass:

// List all registered backend connections
const filesUrl = 'https://{project}-{container}-files-1.{server}.containers.hoody.com';
const backends = await fetch(filesUrl + '/api/v1/backends').then(r => r.json());
// List the root of each one: { path, entries: [{ name, is_dir, size, modified }], count }
for (const backend of backends.backends) {
const listing = await fetch(filesUrl + `/api/v1/files/?backend=${backend.id}`)
.then(r => r.json());
console.log(`${backend.backend_type}: ${listing.count} entries`);
}

Download a file and check it against the reported hash:

async function verifiedDownload(path, backend) {
// 1. Get expected hash: { hash, algorithm: 'sha256' }
const hashResponse = await fetch(
`/api/v1/files${path}?backend=${backend}&hash`
);
const { hash: expectedHash } = await hashResponse.json();
// 2. Download file
const fileResponse = await fetch(
`/api/v1/files${path}?backend=${backend}`
);
const blob = await fileResponse.blob();
// 3. Verify hash (browser)
const arrayBuffer = await blob.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const actualHash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
if (actualHash === expectedHash) {
console.log('✓ Download verified');
return blob;
} else {
throw new Error('Download corrupted - hash mismatch');
}
}

Compare the names and sizes of the files directly inside one local directory with its backup directory. This check does not recurse into subdirectories or compare file contents:

import requests
files_url = 'https://{project}-{container}-files-1.{server}.containers.hoody.com'
def file_sizes(listing):
# Listing shape: {path, entries: [{name, is_dir, size, modified}], count}
return {e['name']: e['size'] for e in listing['entries'] if not e['is_dir']}
def verify_backup(local_path, backup_backend):
# Get local listing
local = requests.get(f'{files_url}/api/v1/files{local_path}').json()
# Get backup listing
backup = requests.get(
f'{files_url}/api/v1/files{local_path}',
params={'backend': backup_backend}
).json()
local_files = file_sizes(local)
backup_files = file_sizes(backup)
missing = set(local_files.keys()) - set(backup_files.keys())
size_mismatch = [
name for name in local_files
if name in backup_files and local_files[name] != backup_files[name]
]
if not missing and not size_mismatch:
print(f'✓ All {len(local_files)} local files have matching names and sizes in the backup; extra backup files were not checked')
else:
print(f'✗ Missing {len(missing)} files, {len(size_mismatch)} size mismatches')

An agent reads a file over HTTP and sends it to a model:

// AI agent reads code file
const filesUrl = 'https://{project}-{container}-files-1.{server}.containers.hoody.com';
const code = await fetch(
filesUrl + '/api/v1/files/app/main.js?backend=8f3a2c1e4b5d6f7a'
).then(r => r.text());
// AI analyzes
const analysis = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{
role: 'user',
content: `Review this code:\n\n${code}`
}]
});
// AI can directly access your files from cloud storage
// No downloading to local machine needed

Compare container storage with a backend, then copy the files the container is missing:

#!/bin/bash
FILES_URL="https://{project}-{container}-files-1.{server}.containers.hoody.com"
BACKEND=2b9d4e6a1c3f5078
# Both listings: {path, entries: [{name, is_dir, size, modified}], count}
local=$(curl -fsS "$FILES_URL/api/v1/files/documents/") || exit 1
remote=$(curl -fsS "$FILES_URL/api/v1/files/documents/?backend=$BACKEND") || exit 1
for listing in "$local" "$remote"; do
jq -e '.entries | type == "array"' <<< "$listing" > /dev/null || exit 1
done
echo "$remote" | jq -r '.entries[] | select(.is_dir | not) | .name' |
while IFS= read -r name; do
if ! echo "$local" | jq -e --arg n "$name" '.entries[] | select(.name == $n)' > /dev/null; then
echo "Copying: $name"
enc=$(jq -rn --arg n "$name" '$n | @uri')
tmp=$(mktemp)
# Read from the backend, then write into container storage
curl -sf "$FILES_URL/api/v1/files/documents/$enc?backend=$BACKEND" -o "$tmp" &&
curl -sf -T "$tmp" "$FILES_URL/api/v1/files/documents/$enc"
rm -f "$tmp"
fi
done

One HTTP interface replaces the per-provider CLIs:

Terminal window
# Traditional: Different CLI for each provider
aws s3 cp s3://bucket/file.pdf ./ # AWS CLI
gcloud storage cp gs://bucket/file.pdf ./ # Google CLI
az storage blob download ... # Azure CLI
# Hoody: One HTTP interface
FILES_URL="https://{project}-{container}-files-1.{server}.containers.hoody.com"
curl "$FILES_URL/api/v1/files/file.pdf?backend=2b9d4e6a1c3f5078" -o file.pdf
curl "$FILES_URL/api/v1/files/file.pdf?backend=5a7c9e1b3d2f4860" -o file.pdf
curl "$FILES_URL/api/v1/files/file.pdf?backend=6b8d0f2a4c1e3759" -o file.pdf

A phone browser reaches every mounted backend:

// Phone browser
const filesUrl = 'https://{project}-{container}-files-1.{server}.containers.hoody.com';
const file = await fetch(
filesUrl + '/api/v1/files/documents/contract.pdf?backend=8f3a2c1e4b5d6f7a'
);
const blob = await file.blob();
// View PDF directly in mobile browser
// No app installation needed

Google Drive, S3, and Dropbox are all reachable from the mobile browser, because every file is an HTTP resource.

Embed a file browser in a page after allowing that page’s HTTPS origin in the --frame-ancestors setting of hoody-files. By default, only pages on the same origin can embed it:

<!-- Live file browser in documentation -->
<iframe src="https://PROJECT-CONTAINER-files-1.SERVER.containers.hoody.com/examples/" height="400" />

Readers see the actual files rather than a description of them.

Verify backups over HTTP:

#!/bin/bash
# Nightly backup verification
FILES_URL="https://{project}-{container}-files-1.{server}.containers.hoody.com"
# ?simple lists one name per line; folder names end with /
curl -s "$FILES_URL/critical/?simple" | grep -v '/$' |
while IFS= read -r file; do
enc=$(jq -rn --arg n "$file" '$n | @uri')
# Get hash from local storage: {"hash": "<hex>", "algorithm": "sha256"}
local_hash=$(curl -fsS "$FILES_URL/api/v1/files/critical/$enc?hash" | jq -er '.hash') || exit 1
# Get hash from the S3 backup
backup_hash=$(curl -fsS "$FILES_URL/api/v1/files/critical/$enc?backend=2b9d4e6a1c3f5078&hash" | jq -er '.hash') || exit 1
if [ "$local_hash" != "$backup_hash" ]; then
echo "WARNING: Backup mismatch: $file"
# Upload the local copy again, or raise an alert
fi
done

An agent lists, reads, and categorizes files:

const filesUrl = 'https://{project}-{container}-files-1.{server}.containers.hoody.com';
// AI agent lists files
const files = await fetch(filesUrl + '/api/v1/files/?backend=8f3a2c1e4b5d6f7a')
.then(r => r.json());
// AI analyzes and categorizes
for (const file of files.entries) {
if (!file.is_dir) {
const name = encodeURIComponent(file.name);
const content = await fetch(
filesUrl + `/api/v1/files/${name}?backend=8f3a2c1e4b5d6f7a`
).then(r => r.text());
// AI determines category
const category = await ai.categorize(content);
// With ?backend= the agent can create folders (POST ?mkdir), upload (PUT)
// and delete (DELETE); a move is a copy uploaded, then the original deleted
}
}

Copy a file from one provider to another through the same service:

// Read from Google Drive
const source = await fetch(
'/api/v1/files/document.pdf?backend=8f3a2c1e4b5d6f7a'
);
if (!source.ok) throw new Error(`Download failed: ${source.status}`);
const file = await source.blob();
// Upload to S3
const destination = await fetch(
'/api/v1/files/document.pdf?backend=2b9d4e6a1c3f5078',
{ method: 'PUT', body: file }
);
if (!destination.ok) throw new Error(`Upload failed: ${destination.status}`);

This example passes the file through the client. ?copy_to= and ?move_to= do not take backend IDs. To copy between providers in one request, mount both under the served filesystem and use their mounted paths with ?copy_to=.


Connect every provider when the container is created:

const providers = [
{ type: 'drive', credentials: googleCreds },
{ type: 's3', credentials: awsCreds },
{ type: 'dropbox', credentials: dropboxCreds }
];
for (const provider of providers) {
await fetch(`/api/v1/backends/${provider.type}`, {
method: 'POST',
body: JSON.stringify(provider.credentials)
});
}
// Now all storage accessible through one interface

For production data, system backups, or compliance checks:

Terminal window
URL="https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/backups/db.dump"
hash=$(curl -s "$URL?hash" | jq -r '.hash')
curl "$URL" -o file
echo "$hash file" | sha256sum -c

Each format suits a different consumer:

  • HTML - Interactive browsing in browser
  • JSON - API integration, processing
  • Simple - Shell scripts, piping to other commands

Directory listings change infrequently:

const cache = new Map();
async function getDirectory(path, backend, ttl = 60000) {
const key = `${path}:${backend}`;
const cached = cache.get(key);
if (cached && Date.now() - cached.time < ttl) {
return cached.data;
}
const data = await fetch(`/api/v1/files${path}?backend=${backend}`)
.then(r => r.json());
cache.set(key, { data, time: Date.now() });
return data;
}

Check that a backend is healthy first:

Terminal window
# Test connection
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/backends/{backend_id}/test"
# 200: {"id": "...", "status": "connected", "message": "Backend is connected and responsive"}
# Go ahead only when the answer is 200 and status is "connected". A 200 can also
# report "disconnected". Ordinary connection failures answer 400
# "Connection test failed: ..."; temporary service refusals can answer
# 409 or 503 with Retry-After.

Cloud providers throttle API calls:

// Add delays between requests
for (const file of files) {
await downloadFile(file);
await new Promise(r => setTimeout(r, 100)); // 100ms delay
}
// Or create a mount with cache_mode "full" and read through its
// mounted path (reads with ?backend= do not use the mount's cache)

How many cloud providers can I mount simultaneously?

Section titled “How many cloud providers can I mount simultaneously?”

The service keeps no fixed list limit on connected backends, and any of the 49 supported backend types can be connected in one container. Live connections are limited by --remote-max-connections (default 100 in total) and --remote-max-per-server (default 10 per host; for backends with no host setting, 10 across that backend type, including different accounts); past either, further connections fail. Each backend has its own ID, and files on every one are reached through the same HTTP interface. Mounts are limited separately; see Mount rules.

Do mounted backends persist across container restarts?

Section titled “Do mounted backends persist across container restarts?”

Yes. Backend configurations are stored in the container’s filesystem. After a restart, backends reconnect automatically, so you do not re-authenticate each time, and mounts on the default settings come back active. A mount with custom settings needs an update to start again; see Service restarts.

Can I mount the same provider multiple times?

Section titled “Can I mount the same provider multiple times?”

Yes, as separate backends. A backend’s ID depends on its type and the settings that name the server or account (host, port, user, account or key IDs, bucket-level addresses), so different servers or accounts get different IDs. Connecting the same server again reuses its ID, including IDs made by earlier versions. When a setting other than a credential names the account, changing only the password or key keeps the ID, and differences only in the environment a backend sets (set_env) or in a header list that is not plain name/value pairs do not make another backend.

A connection that only a credential tells apart, for example an account identified only by its API key, gets a new backend when you connect with a new credential. To rotate such a credential, use PUT /api/v1/backends/{id}, which keeps the ID. port takes a number or a numeric string from 1 to 65535; left out, null or empty, it is the type’s default; anything else is a 400.

How fast are local files versus cloud files?

Section titled “How fast are local files versus cloud files?”

Local files are read straight from container storage. A file on a cloud backend is fetched from the provider, so its speed depends on the provider and the network between it and your server. To speed up files you read repeatedly, mount the backend with POST /api/v1/mounts, give it a vfs_config, and read through the mounted path (reads with ?backend= do not use the mount’s cache): cache_mode of full keeps what it reads in a local cache, and cache_max_size (10 GiB by default) limits the cache’s size on disk. An existing mount takes the same settings through PATCH /api/v1/mounts/{id}. Custom settings need a supported backend type; see Mount settings. Or copy the files you need into container storage.

Yes. An agent makes standard HTTP requests to hoody-files endpoints, so listing directories, reading files, and verifying hashes all work over plain HTTP with no provider-specific SDK.

Yes, but limited to local container storage or specific backends that support writing. Most cloud providers require OAuth scopes for write access. Check backend documentation for write capabilities.

Replacing an existing file in container storage leaves the old version in place until the new one has arrived in full. An upload that is refused for being over the size limit, or that stops because the connection dropped, leaves the existing file exactly as it was. Once the body has arrived, a storage error or service interruption during the final write can leave partial content.

Use container proxy permissions to control who can access files. Configure IP whitelist, password auth, or JWT validation. Until you configure permissions, access is gated only by knowledge of the URL: anyone holding it can reach the files.

Yes. Open the hoody-files URL in your mobile browser: the HTML format gives you a visual file browser, and the JSON format works for a custom mobile app. Google Drive, S3, and local container files are all reachable from the phone through the same interface.

What happens if backend credentials expire?

Section titled “What happens if backend credentials expire?”

File operations fail when backend credentials expire. Rotate the credentials using PUT /api/v1/backends/{id} with the fresh token or secret, which preserves the existing backend ID.

Delete the backend’s mounts first. While a mount of the backend is serving or still has work running, whatever its status, PUT returns 409 Conflict with Cannot update credentials while a mount of this backend is in use. Delete its mounts (DELETE /api/v1/mounts/{mount_id}), then retry. Delete them with DELETE /api/v1/mounts/{mount_id}, retry the PUT, then create the mounts again. The PUT also returns 409 while a delivery of pending uploads to the backend runs.

If you need to change identity fields (host, user, account, type), delete the backend and reconnect with POST /api/v1/backends/{type} to get a new ID.


Problem: Cannot mount storage provider

Solutions:

  1. Verify credentials are correct:

    Terminal window
    # Check OAuth tokens haven't expired
    # Verify API keys are valid
    # Ensure client_id/client_secret match
  2. Check network connectivity:

Terminal window
hoody files backends test {backend_id}
  1. Review provider documentation:
    • Google Drive requires OAuth with an appropriate supported scope, such as drive.readonly
    • S3 needs correct region and credentials
    • Dropbox tokens have app-specific permissions

Problem: File exists but getting 404 response

Check:

  1. Path is case-sensitive:

    /Documents/file.pdf
    # Correct: /documents/file.pdf
  2. Verify file exists:

    Terminal window
    # List parent directory
    curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/documents/?json"
  3. Backend ID is correct:

    Terminal window
    # List all backends
    GET /api/v1/backends
    # Use correct backend_id

Problem: Downloaded file hash doesn’t match expected

Possible causes:

  1. Incomplete download - Re-download with resume support
  2. File modified during download - Re-download to get latest
  3. Network corruption - Use TCP retransmission, verify network
  4. Wrong hash algorithm - Ensure using SHA256

Solution:

Terminal window
# Resume only an incomplete download whose source has not changed.
# For a hash mismatch, download again from the beginning.
curl -fS "$URL" -o file
# Verify against the expected hash for this version
echo "$expected_hash file" | sha256sum -c

Problem: A cloud provider throttles requests, or hoody-files returns 429 because a service concurrency limit has been reached. Provider throttling does not always appear as HTTP 429. For a service limit, wait for running operations to finish before retrying; for provider throttling, try the steps below.

Solutions:

  1. Add delays between requests:

    for (const file of files) {
    await fetch(fileUrl);
    await new Promise(r => setTimeout(r, 200)); // 200ms delay
    }
  2. Mount the backend with a VFS cache:

    Terminal window
    # Cache reads locally so repeats do not hit the provider. Read through
    # the mounted path; reads with ?backend= do not use this cache.
    # Omitting mount_path lets the service place the mount under its own
    # mount directory; an absolute path outside that directory is a 400.
    POST /api/v1/mounts
    {
    "backend_id": "a1b2c3d4e5f60718",
    "vfs_config": {
    "cache_mode": "full",
    "cache_max_size": "10G",
    "cache_max_age": "1h",
    "dir_cache_time": "5m"
    }
    }

    These are custom settings, so the backend’s type must support them: on box, onedrive, protondrive and the other types outside the list under Supported backends, the create returns 400 VFS_CONFIG_UNSUPPORTED_BACKEND and makes no mount.

    There is no cache backend to mount in front of a provider. POST /api/v1/backends/{type} refuses cache, crypt, union, alias, chunker, combine, compress, hasher, memory and local with Backend type '...' is not permitted, because a wrapping backend can be re-pointed at the container’s own filesystem and read outside the served scope. Caching is a property of the mount instead.

  3. Batch operations when possible - Download multiple files in one session

Problem: Timeout or incomplete download for large files

First check the two ceilings for URL imports. A remote download that fetches a URL into container storage (?download or ?download_from) is capped at 100 GiB of transferred bytes and at 12 hours of wall clock. Passing timeout raises nothing: the value is clamped to 12 hours, which is also what you get by omitting it. A transfer that crosses the size cap is aborted and its partial file is deleted, so a truncated result is not left behind for you to resume from. Split a job that will exceed either bound.

Solutions:

  1. Use curl with resume support:

    Terminal window
    curl -C - "https://{project}-{container}-files-1.{server}.containers.hoody.com/large-file.bin" -o large-file.bin
  2. Check disk space before downloading:

    Terminal window
    size=$(curl -fsSI "$URL" | awk 'tolower($1) == "content-length:" {gsub(/\r/, "", $2); print $2}')
    available=$(df -Pk . | awk 'END {print $4 * 1024}')
    # Both values are bytes; ensure available > size before downloading
  3. Download in chunks if supported:

    Terminal window
    # Range works on local files, mounted paths and ?backend= downloads.
    # With ?backend=, more than one range that fits the file, or an
    # If-Range header, gets the whole file (200), so check for 206 Partial
    # Content before concatenating.
    curl -r 0-104857599 "$URL" > part1 # First 100 MiB
    curl -r 104857600- "$URL" > part2 # Rest
    cat part1 part2 > complete-file

The other data services in the kit:

SQLite

Serverless databases over HTTP: queries, a KV store, and time-travel reads.

Explore SQLite →

Exec

Scripts become HTTP endpoints, so your code is served as an API.

Explore Exec →

cURL

Complex HTTP operations reduced to a single GET request against any REST API.

Explore cURL →

More on file operations: