File Management
Apps store their files (HTML, CSS, JavaScript, images, etc.) in an associated Library. The file management endpoints provide CRUD operations for app content.
Important Concepts
Library Association: Each app has a libraryId field that references its file storage. Apps without a library cannot use file management endpoints.
Path Structure: Files are organized hierarchically using forward-slash paths (e.g., index.html, css/styles.css, images/logo.png).
Content Types: Files have MIME types that determine how they're served. The system auto-detects types from file extensions.
Content Recency: File operations do NOT modify the app row. The app's content-modified time is exposed as the derived contentModifiedAt field — the latest modifiedAt across the app's library files (null when the library has no files). The app's own updatedAt/defnUpdatedAt reflect only changes to the app row itself (e.g. draft commits), not file edits.
GET /api/apps/{id}/contents/{path*}
Read a file from the app's library.
Authentication: Required (session or token)
Permissions Required: None for asset paths (read access to the app is
enough). Server-side paths additionally require app:write: the manifest
(informer.yaml or informer.yml), the handler folders (server/, webhooks/,
tools/, mcp/, migrations/, channels/, embeddings/), and _bundles/,
where deploy writes the bundled handlers. Run-level shared users can execute
the app but must not read its server code.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
path | string | File path within the library (e.g., index.html, css/styles.css) |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
download | boolean | false | Force download instead of inline display |
Response:
Returns the file content as a binary stream with appropriate Content-Type header.
Example Request:
GET /api/apps/analytics:sales-dashboard/contents/index.html
Example Response:
HTTP/1.1 200 OK
Content-Type: text/html
<!DOCTYPE html>
<html>
<head><title>Sales Dashboard</title></head>
<body>...</body>
</html>
Download Example:
GET /api/apps/analytics:sales-dashboard/contents/logo.png?download=true
HTTP/1.1 200 OK
Content-Type: image/png
Content-Disposition: attachment; filename="logo.png"
[binary data]
Error Responses:
400 Bad Request- App has no associated library404 Not Found- App doesn't exist, user lacks access, or file not found403 Forbidden- User lacks run permission
PUT /api/apps/{id}/contents/{path*}
Write or create a file in the app's library.
Authentication: Required
Permissions Required: app:write (Designer for Magic Reports, Admin for full Apps)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
path | string | File path within the library |
Payload:
{
"content": "<!DOCTYPE html>\n<html>...",
"contentType": "text/html",
"encoding": "utf8"
}
Payload Fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
content | string | Yes | - | File content (can be empty string) |
contentType | string | No | Auto-detect | MIME type |
encoding | string | No | utf8 | utf8 or base64 |
stagingLibraryId | string | No | - | Id returned by POST /files/_stage. Claims the staged deploy, which sends this write into the staging library instead of the live one |
Returns the created/updated file metadata with status 201 Created (new
file) or 200 OK (updated file).
This route is one of only two write paths that can be redirected into a staging
library (the other is POST /_upload), and the
redirect is claimed rather than automatic. While an app has a staged deploy, a
write that presents the matching stagingLibraryId lands in the staging library;
a write that presents none, or the wrong one, is refused with 409 Conflict
rather than accepted into a live library the promotion is about to replace. See
POST /files/_stage.
{
"content": "<!DOCTYPE html>\n<html>\n <head><title>Order Desk</title></head>\n <body><h1>Open Orders</h1></body>\n</html>"
}
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/contents/orders.html"
}
},
"extension": "html",
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000001",
"embedded": false,
"directory": false,
"libraryId": "00000000-0000-4000-8000-000000000002",
"parentId": null,
"filename": "orders.html",
"contentType": "text/html",
"modifiedAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z",
"createdAt": "2026-01-15T16:20:00.000Z",
"lobId": "16400",
"ownerId": null,
"data": {},
"templateId": null,
"role": null,
"createdById": null,
"letterheadId": null,
"jobActionAttachmentId": null,
"jobActionId": null,
"oauthClientId": null,
"description": null,
"assistantId": null,
"integrationId": null,
"remoteId": null,
"remoteUrl": null,
"size": 524288,
"indexed": false,
"indexingStartedAt": null,
"indexedAt": null,
"indexingErroredAt": null,
"indexingErrorMessage": null,
"messageId": null,
"chatId": null,
"expiresAt": null
}
The modifiedAt field is the per-file content timestamp that backs the
app's derived contentModifiedAt (MAX(file.modifiedAt) across the
library — see Content Recency above).
Example - Create HTML File:
PUT /api/apps/analytics:sales-dashboard/contents/index.html
Content-Type: application/json
{
"content": "<!DOCTYPE html>\n<html>\n <head><title>Dashboard</title></head>\n <body><h1>Sales Dashboard</h1></body>\n</html>",
"contentType": "text/html"
}
Example - Upload Binary File (Base64):
PUT /api/apps/analytics:sales-dashboard/contents/logo.png
Content-Type: application/json
{
"content": "iVBORw0KGgoAAAANSUhEUgAAAAUA...",
"contentType": "image/png",
"encoding": "base64"
}
Side Effects:
- Does NOT modify the app row; bumps the affected file's
modifiedAt, surfaced via the app's derivedcontentModifiedAt - Creates parent directories as needed
- Overwrites existing file if present
- Writing
informer.yamlruns the manifest license gate first (Apps-tier content is rejected with403+apps_license_requiredon a Magic Report or unlicensed tenant) and, on success, eagerly re-binds the app's dependency rows so manifest edits take effect in preview without a deploy
Error Responses:
400 Bad Request- App has no library, or invalid payload404 Not Found- App doesn't exist or user lacks access403 Forbidden- User lacks write permission409 Conflict- A deploy is staged for this app and the request presented nostagingLibraryId, or one that is not the app's current staged upload
PATCH /api/apps/{id}/contents/{path*}
Perform a patch operation on an existing file (replace, append, prepend, or insert text).
Authentication: Required
Permissions Required: app:write (Designer for Magic Reports, Admin for full Apps)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
path | string | File path within the library |
Payload:
The payload structure depends on the operation:
Replace Operation
{
"operation": "replace",
"search": "oldText",
"replacement": "newText",
"replaceAll": false
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
operation | string | Yes | - | Must be "replace" |
search | string | Yes | - | Text to search for |
replacement | string | Yes | - | Replacement text (can be empty) |
replaceAll | boolean | No | false | Replace all occurrences (default: first only) |
Append Operation
{
"operation": "append",
"text": "Text to add at end"
}
Prepend Operation
{
"operation": "prepend",
"text": "Text to add at beginning"
}
Insert Operation
{
"operation": "insert",
"text": "Text to insert",
"insertAt": 100
}
| Field | Type | Required | Description |
|---|---|---|---|
operation | string | Yes | Must be "insert" |
text | string | Yes | Text to insert |
insertAt | integer | Yes | Character position (0-based) |
{
"operation": "replace",
"search": "Open Orders",
"replacement": "Open Orders Today"
}
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/contents/orders.html"
}
},
"extension": "html",
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000002",
"filename": "orders.html",
"embedded": false,
"contentType": "text/html",
"lobId": "16400",
"role": null,
"description": null,
"data": {},
"createdById": null,
"letterheadId": null,
"jobActionAttachmentId": null,
"jobActionId": null,
"oauthClientId": null,
"assistantId": null,
"integrationId": null,
"directory": false,
"indexed": false,
"indexingStartedAt": null,
"indexingErroredAt": null,
"indexingErrorMessage": null,
"indexedAt": null,
"parentId": null,
"libraryId": "00000000-0000-4000-8000-000000000003",
"remoteId": null,
"remoteUrl": null,
"size": 524288,
"modifiedAt": "2026-01-15T16:20:00.000Z",
"messageId": null,
"chatId": null,
"expiresAt": null,
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z",
"ownerId": null,
"templateId": null
}
Side Effects:
- Does NOT modify the app row; bumps the affected file's
modifiedAt, surfaced via the app's derivedcontentModifiedAt - File must exist (PATCH doesn't create new files)
- Cannot be redirected into a staging library, because a patch is a
read-modify-write and a fresh staging library is empty. While a deploy is
staged this route is refused outright with
409 Conflict; seePOST /files/_stage
Error Responses:
400 Bad Request- Invalid operation or missing required fields404 Not Found- App or file doesn't exist403 Forbidden- User lacks write permission409 Conflict- A deploy is staged for this app, so the patch would be discarded when it completes
GET /api/apps/{id}/files
List files in the app's library. Lists the library root by default;
descend with parentId/dir, or search the whole tree with q.
Authentication: Required
Permissions Required: app:run (any user who can run the app)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
parentId | string | null | List children of this directory (null = root) |
dir | string | null | List children of this directory path |
q | string | — | Search the whole tree by filename |
start / end | number | 0 / 100 | Paging window |
dirOnly | boolean | false | Only return directories |
"[{\"id\":\"00000000-0000-4000-8000-000000000002\",\"filename\":\"pages\",\"canIndex\":true,\"remoteUrl\":null,\"directory\":true,\"contentType\":\"application/vnd.apple.pages\",\"indexed\":false,\"modifiedAt\":\"2026-01-15T16:20:00.000Z\",\"indexingErroredAt\":null,\"indexingErrorMessage\":null,\"indexingStartedAt\":null,\"indexedAt\":null,\"path\":\"/pages\",\"size\":null,\"runningDescendantsCount\":0},{\"id\":\"00000000-0000-4000-8000-000000000003\",\"filename\":\"index.html\",\"canIndex\":true,\"remoteUrl\":null,\"directory\":false,\"contentType\":\"text/html\",\"indexed\":false,\"modifiedAt\":\"2026-01-15T16:20:00.000Z\",\"indexingErroredAt\":null,\"indexingErrorMessage\":null,\"indexingStartedAt\":null,\"indexedAt\":null,\"path\":\"/index.html\",\"size\":737,\"runningDescendantsCount\":0},{\"id\":\"00000000-0000-4000-8000-000000000004\",\"filename\":\"informer.yaml\",\"canIndex\":true,\"remoteUrl\":null,\"directory\":false,\"contentType\":\"text/yaml\",\"indexed\":false,\"modifiedAt\":\"2026-01-15T16:20:00.000Z\",\"indexingErroredAt\":null,\"indexingErrorMessage\":null,\"indexingStartedAt\":null,\"indexedAt\":null,\"path\":\"/informer.yaml\",\"size\":283,\"runningDescendantsCount\":0}]"
Key Fields:
| Field | Description |
|---|---|
directory | true if this is a directory, false for files |
parentId | UUID of parent directory (null for root level) |
contentType | MIME type (only present for files) |
size | File size in bytes (only present for files) |
Behavior:
- Always lists the app's live library. A staged deploy never affects this
route: it keeps returning the build that is currently serving, and it never
answers
409. The staging library's files are not visible here until the deploy promotes them.
Error Responses:
404 Not Found- App doesn't exist or user lacks access403 Forbidden- User lacks write permission
DELETE /api/apps/{id}/files/{fileId}
Delete a file or directory from the app's library.
Authentication: Required
Permissions Required: app:write (Designer for Magic Reports, Admin for full Apps)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
fileId | string | File UUID (from the files list) |
Behavior:
- Deleting a directory also deletes all contained files and subdirectories
- Does NOT modify the app row
- Refused while a deploy is staged; see
POST /files/_stage
Error Responses:
404 Not Found- App or file doesn't exist403 Forbidden- User lacks write permission409 Conflict- A deploy is staged for this app, so the delete would be undone when the staged build is promoted
POST /api/apps/{id}/files/_stage
Open a staging library for a deploy and point the app at it.
The two write paths a deploy uses, PUT /contents/{path*} and
POST /_upload, can be redirected into the staging library while it exists, so
the app goes on serving its current library, complete and untouched, for the
whole length of a deploy. POST /api/apps/\{id\}/_deploy then consumes it,
provided it presents this library's id as stagingLibraryId: the live library's
files are dropped, the staged files are re-parented onto it and the route table
is rebuilt, all inside one transaction. A reader sees the previous build right up
to the commit, then the new one.
The files move; the library itself does not. The app's libraryId never
changes, so everything attached to that library - shares, favorites, tags,
comments, assistants and snapshots - is unaffected by a deploy.
This replaces the older wipe-then-upload sequence, where /files/_clear emptied
the live library in its own committed transaction and every request between that
commit and the end of the deploy was served from an empty or partly filled tree.
Authentication: Required
Permissions Required: app:write (Designer for Magic Reports, Admin for full Apps). Same
level as the sibling upload routes. Staging writes no app content, it only allocates the
target the upload pass writes into.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
Behavior:
- Responds
201with the newly created staging library. Keep itsid: a deploy has to present it asstagingLibraryIdto promote the upload. - The app's
libraryIdis left alone. OnlystagingLibraryIdis set, and it isNULLagain once a deploy consumes it. - Reads are never redirected.
GET /contents,GET /files, the app's assets and its deployed route handlers all continue to resolve the live library, for the whole length of a deploy. - An app that already has a staged upload is refused with
409 Conflict. Staging is never taken over: replacing a staged upload silently is how two concurrent deploys interleave, with the loser's files still arriving while the winner publishes a mixture of both builds. Clearing is an explicit act, so a deploy that finds a leftover staged upload callsDELETE /files/_stagefirst and stages again. - Deleting a staging library clears
stagingLibraryIdthrough the foreign key'sAFTER DELETEtrigger, so an abandoned one can be removed safely.
Writes during a staging window
The redirect is claimed, not automatic. Only the deploy that opened the staging
library knows its id, so only that deploy can write into it. Anyone else writing
during the window is refused instead of having their work published as part of
someone else's build, or destroyed along with a discarded staging library, under
a 2xx.
| While a deploy is staged | Behavior |
|---|---|
PUT /contents/{path*} and POST /_upload with the matching stagingLibraryId | Write lands in the staging library |
| The same two routes without it, or with a stale id | 409 Conflict |
| Every other app content write | 409 Conflict |
Every read (GET /contents/{path*}, GET /files, GET /icon, the app's assets and deployed routes) | Unaffected; serves the live build |
The app content writes that answer 409 outright are
PATCH /contents/{path*},
POST /files,
PUT /files/{fileId},
DELETE /files/{fileId},
POST /files/_clear,
POST /snapshots/{snapshotId}/_restore,
PUT /icon,
DELETE /icon,
POST /draft/_commit and
POST /_modernize-manifest.
The refusal follows the files, not the route prefix, so the Library API is refused on the same grounds when it reaches an app's live library: see Library File Management. Installing or updating a marketplace pack onto the app is refused too, before the install applies anything.
Error Responses:
400 Bad Request- The app has no associated library403 Forbidden- User lacks write permission404 Not Found- App doesn't exist409 Conflict- The app already has a staged upload. Another deploy may be in progress; discard it withDELETEbelow before staging again.
Examples:
curl -X POST "${SERVER_URL}/api/apps/myapp/files/_stage" \
-H "Authorization: Basic ${AUTH}"
DELETE /api/apps/{id}/files/_stage
Discard a staged upload and unlock the app's content routes.
A staging window has no natural end if the deploy that opened it never finishes.
Until it is cleared, every content write on the app is refused: the two routes
that can redirect only do so for a caller presenting the staging library's id,
which a dead deploy is no longer around to do, and the rest answer 409
outright. Reads carry on serving the live build, so the app looks healthy while
nobody can change it. The CLI calls this when a deploy fails or is interrupted;
an operator can call it to recover an app left in that state.
Authentication: Required
Permissions Required: app:write
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
stagingLibraryId | string | Only discard if this is still the app's staged upload. Omit to discard whatever is staged. |
Behavior:
- Responds
204. Idempotent: an app with nothing staged is a no-op, so a client can call it unconditionally from a cleanup path. - Passing
stagingLibraryIdis how a deploy avoids discarding a different deploy's in-flight upload; a mismatch responds409and discards nothing. - Abandoned staged uploads are also swept by the
app-sandbox-cleanupsystem job, which discards any staged upload older than 24 hours. The job runs once a day, at 00:17, so an upload staged just after a sweep waits for the one after next: the worst case before collection is about 48 hours, not 24. Calling this route is how you get the app back immediately.
Error Responses:
403 Forbidden- User lacks write permission404 Not Found- App doesn't exist409 Conflict-stagingLibraryIdno longer matches the app's staged upload
Examples:
Discard whatever is staged (what an operator recovering a wedged app does):
curl -X DELETE "${SERVER_URL}/api/apps/myapp/files/_stage" \
-H "Authorization: Basic ${AUTH}"
Discard only this deploy's own upload, leaving a newer one alone:
curl -X DELETE "${SERVER_URL}/api/apps/myapp/files/_stage?stagingLibraryId=${STAGING_LIBRARY_ID}" \
-H "Authorization: Basic ${AUTH}"
POST /api/apps/{id}/files/_clear
Bulk-delete every file in the app's library in a single transaction. Intended
for callers that need to wipe the library before re-uploading a fresh tree.
Deploy workflows should prefer /files/_stage above, which keeps the app
serving while the new build uploads; this route empties the library the app is
actively reading from. One round-trip replaces N parallel per-file
DELETEs; like the per-file path, the bulk-clear leaves the app row untouched
(see Content Recency above).
Authentication: Required
Permissions Required: app:write (Designer for Magic Reports, Admin for full Apps). Same
level as the per-file DELETE — bulk-clear is semantically a multi-row variant of it,
not an app-lifecycle action.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
Behavior:
- Files and directories are removed in a single SQL
DELETEstatement scoped to the app's library; the per-rowfile.parentId → file.idcascade clears the whole tree without ordering concerns. - Large objects (LOBs) for files with stored content are unlinked via the
per-row
delete_file_lobtrigger as part of the cascade. - The bulk delete does NOT touch the app row. After it completes the
library is empty, so the app's derived
contentModifiedAtbecomesnull. - Idempotent: calling on an already-empty library returns
204with no state change. - Refused while a deploy is staged; see
POST /files/_stage
Error Responses:
400 Bad Request- The app has no associated library403 Forbidden- User lacks write permission404 Not Found- App doesn't exist409 Conflict- A deploy is staged for this app, so emptying the live library now would leave it serving nothing until the staged build is promoted
Examples:
curl -X POST "${SERVER_URL}/api/apps/myapp/files/_clear" \
-H "Authorization: Basic ${AUTH}"
POST /api/apps/{id}/files
Create a file or directory under a given parentId. Polymorphic — passing directory: true makes a directory; otherwise an empty file is created. Both share the same endpoint because directories are just File rows with directory: true.
Authentication: Required
Permissions Required: app:write (Designer for Magic Reports, Admin for full Apps)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
Request Body:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
filename | string | Yes | - | Single-segment basename (no slashes). Max 255 chars. |
parentId | string | null | No | null | Containing directory id; null means library root |
directory | boolean | No | false | true to create a directory; false for a file |
{
"filename": "pages",
"directory": true
}
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/files"
}
},
"extension": "",
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000002",
"embedded": false,
"libraryId": "00000000-0000-4000-8000-000000000003",
"parentId": null,
"filename": "pages",
"directory": true,
"contentType": "application/vnd.apple.pages",
"modifiedAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z",
"createdAt": "2026-01-15T16:20:00.000Z",
"lobId": null,
"ownerId": null,
"data": {},
"templateId": null,
"role": null,
"createdById": null,
"letterheadId": null,
"jobActionAttachmentId": null,
"jobActionId": null,
"oauthClientId": null,
"description": null,
"assistantId": null,
"integrationId": null,
"remoteId": null,
"remoteUrl": null,
"size": null,
"indexed": false,
"indexingStartedAt": null,
"indexedAt": null,
"indexingErroredAt": null,
"indexingErrorMessage": null,
"messageId": null,
"chatId": null,
"expiresAt": null
}
Behavior:
- Rejects sibling-name collisions with
409 Conflict(no silent overwrite —PUT /contentsis the upsert endpoint) - Files get an empty content blob; reads of new files succeed and return zero bytes
parentIdmust reference an existing directory in the same library, or benull- Does NOT modify the app row; bumps the affected file's
modifiedAt, surfaced via the app's derivedcontentModifiedAt - Refused while a deploy is staged; see
POST /files/_stage
Error Responses:
404 Not Found- App doesn't exist403 Forbidden- User lacks write permission409 Conflict(name collision) - A file or directory with the samefilenamealready exists underparentId409 Conflict(staged deploy) - A deploy is staged for this app, so the new file would be discarded when the staged build is promoted. Unlike the collision above, a differentfilenamedoes not help: nothing can be created here until the deploy finishes or is discarded400 Bad Request- Missing/invalidfilename, unknownparentId, orparentIdreferences a non-directory
Example - Create a file at the root:
curl -X POST "${SERVER_URL}/api/apps/myapp/files" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"filename": "index.html"}'
Example - Create a directory under another directory:
curl -X POST "${SERVER_URL}/api/apps/myapp/files" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"filename": "components", "parentId": "src-dir-uuid", "directory": true}'
PUT /api/apps/{id}/files/{fileId}
Rename and/or move a file or directory. Both filename and parentId are independently optional — pass either, both, or hit the no-op path by passing the current values.
Authentication: Required
Permissions Required: app:write (Designer for Magic Reports, Admin for full Apps)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
fileId | string | File UUID (from the files list) |
Request Body:
| Field | Type | Description |
|---|---|---|
filename | string | New basename (single segment, no slashes, max 255 chars) |
parentId | string | null | New parent directory id; null means library root |
At least one of filename or parentId must be present.
{
"filename": "home.html",
"parentId": "00000000-0000-4000-8000-000000000003"
}
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/files/00000000-0000-4000-8000-000000000002"
}
},
"extension": "html",
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000002",
"filename": "home.html",
"embedded": false,
"contentType": "text/html",
"lobId": "16400",
"role": null,
"description": null,
"data": {},
"createdById": null,
"letterheadId": null,
"jobActionAttachmentId": null,
"jobActionId": null,
"oauthClientId": null,
"assistantId": null,
"integrationId": null,
"directory": false,
"indexed": false,
"indexingStartedAt": null,
"indexingErroredAt": null,
"indexingErrorMessage": null,
"indexedAt": null,
"parentId": "00000000-0000-4000-8000-000000000003",
"libraryId": "00000000-0000-4000-8000-000000000004",
"remoteId": null,
"remoteUrl": null,
"size": "524288",
"modifiedAt": "2026-01-15T16:20:00.000Z",
"messageId": null,
"chatId": null,
"expiresAt": null,
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z",
"ownerId": null,
"templateId": null
}
Behavior:
- Pure rename (
filenameonly), pure move (parentIdonly), or both in one call - For directories, children's paths follow automatically (paths derive from the parentId chain)
- Cycle prevention: cannot move a directory into itself or any of its descendants
- Does NOT modify the app row; bumps the affected file's
modifiedAt, surfaced via the app's derivedcontentModifiedAt - Refused while a deploy is staged; see
POST /files/_stage
Error Responses:
404 Not Found- App or file doesn't exist403 Forbidden- User lacks write permission409 Conflict(name collision) - A sibling with the newfilenamealready exists underparentId409 Conflict(staged deploy) - A deploy is staged for this app, so the rename or move would be undone when the staged build is promoted. Unlike the collision above, a freefilenamedoes not help: nothing can be renamed or moved here until the deploy finishes or is discarded400 Bad Request- Invalid filename, unknownparentId,parentIdreferences a non-directory, or attempted cycle
Example - Rename in place:
curl -X PUT "${SERVER_URL}/api/apps/myapp/files/${fileId}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"filename": "Header.tsx"}'
Example - Move to a different directory:
curl -X PUT "${SERVER_URL}/api/apps/myapp/files/${fileId}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"parentId": "components-dir-uuid"}'
POST /api/apps/{id}/_upload
Upload a file using chunked multipart upload. This endpoint is designed for large file uploads from web browsers.
Authentication: Required
Permissions Required: app:write (Designer for Magic Reports, Admin for full Apps)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
Payload:
JSON. The bytes themselves are sent to the chunked upload flow first, which
yields an uploadId; this route assembles that upload into the app's library.
| Field | Type | Required | Description |
|---|---|---|---|
uploadId | string | Yes | Id returned by the chunked (Flow.js) upload |
path | string | No | File path within the library (e.g. assets/index.js). Parent directories are created as needed |
stagingLibraryId | string | No | Id returned by POST /files/_stage. Claims the staged deploy, which sends this upload into the staging library instead of the live one |
Response:
Returns the created file, with a Location header pointing at it.
Use Case:
This endpoint is used by the file upload UI for:
- Large binary files
- Progress tracking
- Resume capability
Along with PUT /contents/{path*}, it is one of
the two write paths a staged deploy can use. The same claimed-redirect rule
applies: while a deploy is staged, an upload presenting the matching
stagingLibraryId lands in the staging library, and one presenting none (or a
stale id) is refused with 409 Conflict.
Error Responses:
404 Not Found- App doesn't exist or user lacks access403 Forbidden- User lacks write permission409 Conflict- A deploy is staged for this app and the request presented nostagingLibraryId, or one that is not the app's current staged upload
Common File Operations
Create a Simple App
// 1. Create the app
const app = await POST('/api/apps', {
type: 'report',
name: 'My Dashboard'
});
// 2. Add HTML file
await PUT(`/api/apps/${app.id}/contents/index.html`, {
content: '<!DOCTYPE html><html>...',
contentType: 'text/html'
});
// 3. Add CSS file
await PUT(`/api/apps/${app.id}/contents/styles.css`, {
content: 'body { margin: 0; }',
contentType: 'text/css'
});
// 4. Add JavaScript file
await PUT(`/api/apps/${app.id}/contents/app.js`, {
content: 'console.log("Ready");',
contentType: 'application/javascript'
});
Update a File
// Read current content
const response = await GET('/api/apps/analytics:sales-dashboard/contents/index.html');
const currentHTML = await response.text();
// Modify it
const updatedHTML = currentHTML.replace('Old Title', 'New Title');
// Write back
await PUT('/api/apps/analytics:sales-dashboard/contents/index.html', {
content: updatedHTML,
contentType: 'text/html'
});
Bulk Replace Across File
// Replace all occurrences of a string
await PATCH('/api/apps/analytics:sales-dashboard/contents/index.html', {
operation: 'replace',
search: 'oldClassName',
replacement: 'newClassName',
replaceAll: true
});
List and Delete Old Files
// Get all files
const files = await GET('/api/apps/analytics:sales-dashboard/files');
// Find old temp files
const tempFiles = files.filter(f => f.filename.startsWith('temp-'));
// Delete them
for (const file of tempFiles) {
await DELETE(`/api/apps/analytics:sales-dashboard/files/${file.id}`);
}