SQLite
Serverless databases over HTTP: queries, a KV store, and time-travel reads.
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:
?zip parameter when archive downloads are enabledEach link below goes to the reference page for that endpoint, with all parameters, responses, and examples.
File reading and downloading:
backend, base64json, simple, content-typeFile integrity:
Writing and moving:
204 with no bodySearch and inspection:
HEAD returnsArchive operations:
dest picks another destination, relative to the archive’s folderRemote downloads:
Directory listing:
sort (name|size|mtime), order (asc|desc)Reaching a remote without mounting it:
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 restartPersistent FUSE mounts:
vfs_config for caching and write behaviourvfs_config in place. Fields you omit keep their current value, and the mount path stays the same. See Mount settingsPending uploads (files a mount could not upload; see Pending uploads):
backend_idWebDAV:
Backend management:
POST /api/v1/mounts to mount it into the filesystem
local, crypt, union, alias, cache and the rest) are deliberately not offeredJournal:
System monitoring:
features list of optional capabilities (put_chmod: PUT accepts ?chmod=)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.comThe interface provides:
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.
/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.., _ 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.DELETE /api/v1/backends/{id}. The web UI has no button for it.The same request shape works against every mounted backend:
# Container files URL: https://{project}-{container}-files-1.{server}.containers.hoody.com
# Local container filescurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/documents/report.pdf"
# Google Drivecurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/Work/report.pdf?backend=8f3a2c1e4b5d6f7a"
# Amazon S3curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/backups/data.zip?backend=2b9d4e6a1c3f5078"
# Dropboxcurl "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:
# --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..."}'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
const backend = await containerClient.files.backends.createDrive({ client_id: 'your-app.apps.googleusercontent.com', client_secret: 'your-secret', token: '{"access_token":"ya29..."}',});console.log(backend.data.data.id); // 16-char hex backend IDcurl -X POST "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/backends/drive" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-app.apps.googleusercontent.com", "client_secret": "your-secret", "token": "{\"access_token\":\"ya29...\"}" }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Connects a Google Drive backend to this container in one request. The client secret and token travel inside the link itself, so treat it like the raw credential rather than pasting it somewhere shared.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-files-1.SERVER.containers.hoody.com/api/v1/backends/drive&method=POST&json={"client_id":"your-app.apps.googleusercontent.com","client_secret":"your-secret","token":"{\"access_token\":\"ya29...\"}"}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
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:
# List Drive rootcurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/?backend=9b4d3e2f5a6c7890"
# Download Drive filecurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/Documents/report.pdf?backend=9b4d3e2f5a6c7890" \ --output report.pdfA 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.
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.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”.400 “Mount path is reserved by the service”.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.vfs_config that differs from the defaults runs the mount with those settings. See Mount settings.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.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.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.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:
uploads | What 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 |
wait | The 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 |
discard | What 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.
| Field | Default | Accepted values |
|---|---|---|
cache_mode | writes | off, minimal, writes or full, in lowercase |
cache_max_size | 10G | Bytes, or a size such as 512M or 20G. 0 means no limit; otherwise 1 MiB to 9007199254740991 bytes |
cache_max_age | 1h | Seconds, or a duration such as 30m or 1h30m. At most 30 days |
read_chunk_size | 128M | Bytes, or a size. 1 MiB to 1 GiB |
write_back | 5s | How long a closed file waits before it is uploaded. Seconds, or a duration. At most 1 hour |
dir_cache_time | 5m | How long directory listings are cached. Seconds, or a duration. At most 30 days |
poll_interval | 1m | How often the backend is checked for changes. Seconds, or a duration. At most 30 days |
read_only | false | true or false. true makes the mount path refuse writes |
timeout | Unset (5m) | How long a transfer may stay idle before it fails. Seconds, or a duration; 1 to 600 seconds |
contimeout | Unset (1m) | How long connecting may take before it fails. Seconds, or a duration; 1 to 120 seconds |
low_level_retries | Unset (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.
hoody files mounts update mount_550e8400e29b41d4a716446655440000 \ --container "$CONTAINER_ID" \ --vfs-config-cache-mode full \ --vfs-config-cache-max-size 20Gconst result = await containerClient.files.mounts.update('mount_550e8400e29b41d4a716446655440000', { vfs_config: { cache_mode: 'full', cache_max_size: '20G' },});console.log(result.data.remounted, result.data.data.vfs_config);curl -X PATCH "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/mounts/mount_550e8400e29b41d4a716446655440000" \ -H "Content-Type: application/json" \ -d '{"vfs_config": {"cache_mode": "full", "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.
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”.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.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.cd into it again.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.?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.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.active at once if its backend reconnects. Otherwise it is in status error with the message “backend unavailable” until the backend is connected again.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.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”.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.
| Status | Code | Meaning |
|---|---|---|
400 | VFS_PATCH_EMPTY | The body sets neither vfs_config nor auto_deliver, or sends vfs_config as {} |
400 | VFS_FIELD_UNKNOWN | The body or vfs_config names an unknown field |
400 | VFS_FIELD_NULL | A field is null |
400 | VFS_FIELD_NOT_SETTABLE | allow_other or allow_non_empty is true |
400 | VFS_CONFIG_INVALID | A value has the wrong type or is outside the limits, or a field is given twice |
400 | VFS_CONFIG_UNSUPPORTED_BACKEND | Custom settings on a backend type that does not support them |
400 | INVALID_JSON | The body is not valid JSON, or not a JSON object |
400 | INVALID_PARAMETER | switch is not wait or immediate |
400 | MOUNT_PATH_INVALID | Repairing a saved mount whose path is not valid under the mount directory |
400 | MOUNT_PATH_RESERVED | The mount is at a path the service keeps for its own files. Delete the mount and create it at another path |
403 | none | Remote mounts are turned off on this service |
404 | MOUNT_NOT_FOUND | No mount has this ID |
409 | MOUNT_UPDATE_IN_PROGRESS | Closed 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 |
409 | MOUNT_BUSY | Another 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 |
409 | MOUNT_BACKEND_UNAVAILABLE | The mount’s backend is not connected. Connect it again, then retry |
409 | MOUNT_PATH_IN_USE | Repairing a saved mount whose path another mount now uses, or where a file operation is writing |
409 | MOUNT_PATH_CHANGED | Something 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 |
413 | PAYLOAD_TOO_LARGE | The body is larger than 64 KiB |
429 | DEDICATED_VFS_LIMIT | The 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 |
500 | MOUNT_PERSIST_FAILED | The new settings could not be saved, and the mount keeps its previous settings |
500 | MOUNT_SWAP_FAILED | The 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 |
502 | MOUNT_REMOUNT_FAILED | The mount could not start with the new settings, for example because the backend refused the connection. The mount keeps its previous settings |
503 | MOUNT_STORAGE_UNAVAILABLE | The 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 |
507 | PENDING_UPLOADS_LIMIT | Unsent 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.
| Route | What it does | Answer |
|---|---|---|
GET /api/v1/pending-uploads | List the groups. ?backend_id= limits it to one backend | 200 |
GET /api/v1/pending-uploads/{id}/files | List 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 absent | 200 |
GET /api/v1/pending-uploads/{id}/file | Download the held copy of one file, named with path or path_b64 as listed (exactly one). Takes Range and conditional requests | 200, 206, 304, 412, 416 |
POST /api/v1/pending-uploads/{id}/deliver | Upload held files | 202 |
POST /api/v1/pending-uploads/{id}/stop | Stop an uploading or delivering group; its unsent files are held as pending | 200 |
DELETE /api/v1/pending-uploads/{id} | Delete every held file of the group | 204 |
A file marked complete: false may be missing ranges, which read as zeros. It can only be downloaded or discarded; delivery skips it.
# List what is heldcurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/pending-uploads"
# Upload every complete file of one group with its saved connection settingscurl -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:
| Status | Code | Meaning |
|---|---|---|
400 | INVALID_PATH, INVALID_PARAMETER, INVALID_JSON | A name is not one of the group’s files, a parameter is malformed, or the body is not valid |
403 | REMOTE_DISABLED | Remote access is off |
404 | PENDING_UPLOAD_NOT_FOUND, PENDING_UPLOAD_FILE_NOT_FOUND, BACKEND_NOT_FOUND | No such group, held file or backend. A group whose files were all delivered or discarded is gone |
409 | PENDING_UPLOAD_BUSY | The group is uploading or being delivered. Stop it or wait. Also returned by stop on a pending group |
409 | PENDING_UPLOAD_NOT_DELIVERABLE | The 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. |
409 | PENDING_UPLOAD_FILE_INCOMPLETE | A named file is incomplete (complete: false); nothing was delivered. Leave it out, or download or discard it |
413 | PAYLOAD_TOO_LARGE | The body is over 4 MiB |
429 | DEDICATED_VFS_LIMIT | The limit of mounts with custom settings is reached; a delivery counts against it |
500 | PENDING_UPLOAD_PERSIST_FAILED | The 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:
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./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.error with the message “mounts are unavailable until the server’s storage check passes”.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.409 “The path changed while the operation ran; retry.”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.
/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.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.GET /api/v1/journal and the file history reads, and thumbnails through the thumbnail route.Pick the format that suits the caller:
# Interactive file browseropen "https://{project}-{container}-files-1.{server}.containers.hoody.com/documents/"Interactive file browser with visual navigation, search, sortable columns, and upload interface (if permitted).
# Structured datacurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/documents/?json"{ "href": "/documents/", "kind": "Index", "dir_exists": true, "paths": [ { "name": "report.pdf", "path_type": "File", "size": 524288, "mtime": 1699564800000 } ], "...": "..."}path_type is File, Dir, SymlinkFile or SymlinkDir, and mtime is in milliseconds. The body also carries uri_prefix, auth, user, and allow_upload, allow_delete, allow_search, allow_archive and allow_remote, which say what the service permits. A file with recorded history also has a revisions count.
# Clean text for scriptscurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/images/?simple"photo1.jpgphoto2.jpgvacation/One item per line. Directories end with /. The lines are sent with the content type text/html; charset=utf-8, so read the body as text whatever the header says.
Check a download against the hash the service reports:
# Container files URLFILES_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 filecurl "$FILES_URL/api/v1/files/large-file.bin" -o large-file.bin
# 3. Verifyactual=$(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:
Preview an archive without extracting it:
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/backup.tar.gz?preview"# Lists contents without downloading full archiveBase64-encode a file for embedding:
curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/config.json?base64"# Returns: eyJrZXkiOiJ2YWx1ZSJ9The 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:
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 attachmentA 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:
# Download entire directory as .zip archivecurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/documents/?zip" \ -o documents.zipA 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:
hoody files grep /workspace --pattern 'TODO' --glob '*.{ts,tsx}' -c <containerId> -o jsonimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const { data } = await client.api.containers.list();const containerClient = await client.withContainer(data.containers![0]!);
const result = await containerClient.files.grep('workspace', { pattern: 'TODO', glob: '*.{ts,tsx}',});
for (const m of result.data.matches) { console.log(`${m.path}:${m.line_number}: ${m.line}`);}curl --get "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/grep/workspace" \ --data-urlencode 'pattern=TODO' \ --data-urlencode 'glob=*.{ts,tsx}'The same search works on the file route: GET /api/v1/files/workspace?grep=TODO&glob=*.go.
The glob filter takes .gitignore-style patterns:
| Pattern | Searches |
|---|---|
*.go | Files 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.go | main.go at the top of the searched folder only |
!*_test.go | Every 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.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:
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"}mode in canonical octal. 644, 0600, 0o755 and 000 are accepted.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):
| Flag | Env | Default | Meaning |
|---|---|---|---|
--default-create-owner <spec> | HOODY_FILE_MANAGER_DEFAULT_CREATE_OWNER | user | Owner 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:
.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.?extract and by ?download or ?download_from are not recorded.journal-max-file-size (default 2 MiB) are recorded as an event with size and hash, but with no content snapshot.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:
hoody files get src/app.ts --history --limit 50import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const { data } = await client.api.containers.list();const containerClient = await client.withContainer(data.containers![0]!);
// files.get returns several shapes depending on the query; name the history fields read heretype FileHistory = { revisions: { seq?: number; op: string; ts: number }[]; has_more: boolean };const res = await containerClient.files.get('src/app.ts', { history: '', limit: 50 });const history = res.data as unknown as FileHistory;
for (const rev of history.revisions) { console.log(`#${rev.seq} [${rev.op}] ${rev.ts}`);}curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/src/app.ts?history&limit=50"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Lists the recorded revisions of src/app.ts, 50 per page.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-files-1.SERVER.containers.hoody.com/api/v1/files/src/app.ts?history%26limit=50&method=GET&response=transparent 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:
# By revision numberhoody files get src/app.ts --revision 3
# By timestamphoody files get src/app.ts --at "2026-03-19T14:30:00Z"// By revisionconst v3 = await containerClient.files.get('src/app.ts', { revision: 3 });
// By timestampconst yesterday = await containerClient.files.get('src/app.ts', { at: '2026-03-19T14:30:00Z' });# By revisioncurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/src/app.ts?revision=3"
# By timestampcurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/src/app.ts?at=2026-03-19T14:30:00Z"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Fetches a stored revision of src/app.ts, or the latest retained version at or before the given timestamp.
# By revision
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-files-1.SERVER.containers.hoody.com/api/v1/files/src/app.ts?revision=3&method=GET&response=transparent
# By timestamp
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-files-1.SERVER.containers.hoody.com/api/v1/files/src/app.ts?at=2026-03-19T14:30:00Z&method=GET&response=transparent 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:
hoody files get src/app.ts --diff --from-seq 1 --to-seq 3const diff = await containerClient.files.get('src/app.ts', { diff: '', from_seq: 1, to_seq: 3 });console.log(diff);curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/src/app.ts?diff&from_seq=1&to_seq=3"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Returns a unified diff of src/app.ts between revision 1 and revision 3.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-files-1.SERVER.containers.hoody.com/api/v1/files/src/app.ts?diff%26from_seq=1%26to_seq=3&method=GET&response=transparent Search across all mutations in your container and monitor journal health:
# Query the first 20 retained write events under src/hoody files journal list --path src/ --op write --limit 20
# View journal storage statisticshoody files journal stats// Query journal entriesconst entries = await containerClient.files.journal.list({ path: 'src/', op: 'write', limit: 20 });
// Get journal statsconst stats = await containerClient.files.journal.getStats();console.log(`${stats.data.total_entries} entries, ${stats.data.total_blobs} blobs`);# Query journalcurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/journal?path=src/&op=write&limit=20"
# Journal statscurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/journal/stats"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Lists the first 20 retained write events under src/ and reports overall journal storage stats.
# Query journal
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-files-1.SERVER.containers.hoody.com/api/v1/journal?path=src/%26op=write%26limit=20&method=GET&response=transparent
# Journal stats
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-files-1.SERVER.containers.hoody.com/api/v1/journal/stats&method=GET&response=transparent 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:
One HTTP API for everything:https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/files/{path}?backend={backend_id}That gives you:
A phone browser reaches the same files:
// From mobile browserawait 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 connectionsconst 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 fileconst 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 analyzesconst 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 neededCompare 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 1remote=$(curl -fsS "$FILES_URL/api/v1/files/documents/?backend=$BACKEND") || exit 1for listing in "$local" "$remote"; do jq -e '.entries | type == "array"' <<< "$listing" > /dev/null || exit 1done
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" fidoneOne HTTP interface replaces the per-provider CLIs:
# Traditional: Different CLI for each provideraws s3 cp s3://bucket/file.pdf ./ # AWS CLIgcloud storage cp gs://bucket/file.pdf ./ # Google CLIaz storage blob download ... # Azure CLI
# Hoody: One HTTP interfaceFILES_URL="https://{project}-{container}-files-1.{server}.containers.hoody.com"
curl "$FILES_URL/api/v1/files/file.pdf?backend=2b9d4e6a1c3f5078" -o file.pdfcurl "$FILES_URL/api/v1/files/file.pdf?backend=5a7c9e1b3d2f4860" -o file.pdfcurl "$FILES_URL/api/v1/files/file.pdf?backend=6b8d0f2a4c1e3759" -o file.pdfA phone browser reaches every mounted backend:
// Phone browserconst 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 neededGoogle 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 verificationFILES_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 fidoneAn agent lists, reads, and categorizes files:
const filesUrl = 'https://{project}-{container}-files-1.{server}.containers.hoody.com';
// AI agent lists filesconst files = await fetch(filesUrl + '/api/v1/files/?backend=8f3a2c1e4b5d6f7a') .then(r => r.json());
// AI analyzes and categorizesfor (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 Driveconst 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 S3const 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 interfaceFor production data, system backups, or compliance checks:
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 fileecho "$hash file" | sha256sum -cEach format suits a different consumer:
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:
# Test connectioncurl "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 requestsfor (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)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.
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.
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.
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.
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:
Verify credentials are correct:
# Check OAuth tokens haven't expired# Verify API keys are valid# Ensure client_id/client_secret matchCheck network connectivity:
hoody files backends test {backend_id}const test = await containerClient.files.backends.test('{backend_id}');console.log(test.data.status, test.data.message); // "connected" or "disconnected"curl "https://{project}-{container}-files-1.{server}.containers.hoody.com/api/v1/backends/{backend_id}/test"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Tests one connected backend by listing its root directory. The backend does not need a mount.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-files-1.SERVER.containers.hoody.com/api/v1/backends/BACKEND_ID/test&method=GET&response=transparent drive.readonlyProblem: File exists but getting 404 response
Check:
Path is case-sensitive:
# Correct: /documents/file.pdfVerify file exists:
# List parent directorycurl "https://{project}-{container}-files-1.{server}.containers.hoody.com/documents/?json"Backend ID is correct:
# List all backendsGET /api/v1/backends# Use correct backend_idProblem: Downloaded file hash doesn’t match expected
Possible causes:
Solution:
# 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 versionecho "$expected_hash file" | sha256sum -cProblem: 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:
Add delays between requests:
for (const file of files) { await fetch(fileUrl); await new Promise(r => setTimeout(r, 200)); // 200ms delay}Mount the backend with a VFS cache:
# 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.
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:
Use curl with resume support:
curl -C - "https://{project}-{container}-files-1.{server}.containers.hoody.com/large-file.bin" -o large-file.binCheck disk space before downloading:
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 downloadingDownload in chunks if supported:
# 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 MiBcurl -r 104857600- "$URL" > part2 # Restcat part1 part2 > complete-fileThe other data services in the kit:
SQLite
Serverless databases over HTTP: queries, a KV store, and time-travel reads.
Exec
Scripts become HTTP endpoints, so your code is served as an API.
cURL
Complex HTTP operations reduced to a single GET request against any REST API.
More on file operations: