Skip to main content

File Management

Looking for the product view?
The Help Center explains this surface as users see it: Logs, Snapshots & Files.

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 source paths (informer.yaml, server/, webhooks/, tools/, migrations/) additionally require app:write: run-level shared users can execute the app but must not read its server code.

Path Parameters:

ParameterTypeDescription
idstringApp UUID or natural ID
pathstringFile path within the library (e.g., index.html, css/styles.css)

Query Parameters:

ParameterTypeDefaultDescription
downloadbooleanfalseForce 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 library
  • 404 Not Found - App doesn't exist, user lacks access, or file not found
  • 403 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:

ParameterTypeDescription
idstringApp UUID or natural ID
pathstringFile path within the library

Payload:

{
"content": "<!DOCTYPE html>\n<html>...",
"contentType": "text/html",
"encoding": "utf8"
}

Payload Fields:

FieldTypeRequiredDefaultDescription
contentstringYes-File content (can be empty string)
contentTypestringNoAuto-detectMIME type
encodingstringNoutf8utf8 or base64

Returns the created/updated file metadata with status 201 Created (new file) or 200 OK (updated file).

Write a filePUT /api/apps/admin%3Aorder-desk/contents/orders.html
Upsert: 201 when the file is created, 200 when overwritten. Writing informer.yaml additionally runs the manifest license gate and eagerly re-binds dependencies.
Request body
{
"content": "<!DOCTYPE html>\n<html>\n <head><title>Order Desk</title></head>\n <body><h1>Open Orders</h1></body>\n</html>"
}
Response · 201
{
"_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
}
Captured from the API examples test suite; ids and timestamps are normalized.

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 derived contentModifiedAt
  • Creates parent directories as needed
  • Overwrites existing file if present
  • Writing informer.yaml runs the manifest license gate first (Apps-tier content is rejected with 403 + apps_license_required on 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 payload
  • 404 Not Found - App doesn't exist or user lacks access
  • 403 Forbidden - User lacks write permission

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:

ParameterTypeDescription
idstringApp UUID or natural ID
pathstringFile path within the library

Payload:

The payload structure depends on the operation:

Replace Operation

{
"operation": "replace",
"search": "oldText",
"replacement": "newText",
"replaceAll": false
}
FieldTypeRequiredDefaultDescription
operationstringYes-Must be "replace"
searchstringYes-Text to search for
replacementstringYes-Replacement text (can be empty)
replaceAllbooleanNofalseReplace 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
}
FieldTypeRequiredDescription
operationstringYesMust be "insert"
textstringYesText to insert
insertAtintegerYesCharacter position (0-based)
Patch a filePATCH /api/apps/00000000-0000-4000-8000-000000000001/contents/orders.html
Operations: replace (search/replacement/replaceAll), append (text), prepend (text), insert (text/insertAt). The file must already exist.
Request body
{
"operation": "replace",
"search": "Open Orders",
"replacement": "Open Orders Today"
}
Response · 200
{
"_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,
"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,
"integrationId": null
}
Captured from the API examples test suite; ids and timestamps are normalized.

Side Effects:

  • Does NOT modify the app row; bumps the affected file's modifiedAt, surfaced via the app's derived contentModifiedAt
  • File must exist (PATCH doesn't create new files)

Error Responses:

  • 400 Bad Request - Invalid operation or missing required fields
  • 404 Not Found - App or file doesn't exist
  • 403 Forbidden - User lacks write permission

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:

ParameterTypeDescription
idstringApp UUID or natural ID

Query Parameters:

ParameterTypeDefaultDescription
parentIdstringnullList children of this directory (null = root)
dirstringnullList children of this directory path
qstringSearch the whole tree by filename
start / endnumber0 / 100Paging window
dirOnlybooleanfalseOnly return directories
List an App's filesGET /api/apps/00000000-0000-4000-8000-000000000001/files
Lists the library ROOT by default; pass parentId (or dir) to descend into a directory, q to search the whole tree, dirOnly to filter to directories.
Response · 200
"[{\"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}]"
Captured from the API examples test suite; ids and timestamps are normalized.

Key Fields:

FieldDescription
directorytrue if this is a directory, false for files
parentIdUUID of parent directory (null for root level)
contentTypeMIME type (only present for files)
sizeFile size in bytes (only present for files)

Error Responses:

  • 404 Not Found - App doesn't exist or user lacks access
  • 403 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:

ParameterTypeDescription
idstringApp UUID or natural ID
fileIdstringFile UUID (from the files list)
Delete a fileDELETE /api/apps/00000000-0000-4000-8000-000000000001/files/00000000-0000-4000-8000-000000000002
Deleting a directory removes its whole subtree.
Response · 204 · no body
Captured from the API examples test suite; ids and timestamps are normalized.

Behavior:

  • Deleting a directory also deletes all contained files and subdirectories
  • Does NOT modify the app row

Error Responses:

  • 404 Not Found - App or file doesn't exist
  • 403 Forbidden - User lacks write permission

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 (e.g. CLI deploy workflows). 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:

ParameterTypeDescription
idstringApp UUID or natural ID
Clear an App's libraryPOST /api/apps/00000000-0000-4000-8000-000000000001/files/_clear
Bulk-deletes every file in one transaction; used by CLI deploys before re-uploading a fresh tree. Idempotent.
Response · 204 · no body
Captured from the API examples test suite; ids and timestamps are normalized.

Behavior:

  • Files and directories are removed in a single SQL DELETE statement scoped to the app's library; the per-row file.parentId → file.id cascade clears the whole tree without ordering concerns.
  • Large objects (LOBs) for files with stored content are unlinked via the per-row delete_file_lob trigger 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 contentModifiedAt becomes null.
  • Idempotent: calling on an already-empty library returns 204 with no state change.

Error Responses:

  • 400 Bad Request - The app has no associated library
  • 403 Forbidden - User lacks write permission
  • 404 Not Found - App doesn't exist

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:

ParameterTypeDescription
idstringApp UUID or natural ID

Request Body:

FieldTypeRequiredDefaultDescription
filenamestringYes-Single-segment basename (no slashes). Max 255 chars.
parentIdstring | nullNonullContaining directory id; null means library root
directorybooleanNofalsetrue to create a directory; false for a file
Create a directoryPOST /api/apps/00000000-0000-4000-8000-000000000001/files
POST /files creates files and directories; sibling-name collisions are 409. PUT /contents is the upsert path for file content.
Request body
{
"filename": "pages",
"directory": true
}
Response · 201
{
"_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
}
Captured from the API examples test suite; ids and timestamps are normalized.

Behavior:

  • Rejects sibling-name collisions with 409 Conflict (no silent overwrite — PUT /contents is the upsert endpoint)
  • Files get an empty content blob; reads of new files succeed and return zero bytes
  • parentId must reference an existing directory in the same library, or be null
  • Does NOT modify the app row; bumps the affected file's modifiedAt, surfaced via the app's derived contentModifiedAt

Error Responses:

  • 404 Not Found - App doesn't exist
  • 403 Forbidden - User lacks write permission
  • 409 Conflict - A file or directory with the same filename already exists under parentId
  • 400 Bad Request - Missing/invalid filename, unknown parentId, or parentId references 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:

ParameterTypeDescription
idstringApp UUID or natural ID
fileIdstringFile UUID (from the files list)

Request Body:

FieldTypeDescription
filenamestringNew basename (single segment, no slashes, max 255 chars)
parentIdstring | nullNew parent directory id; null means library root

At least one of filename or parentId must be present.

Rename or move a filePUT /api/apps/00000000-0000-4000-8000-000000000001/files/00000000-0000-4000-8000-000000000002
filename and parentId are independently optional: rename in place, move, or both in one call. Moving a directory carries its children.
Request body
{
"filename": "home.html",
"parentId": "00000000-0000-4000-8000-000000000003"
}
Response · 200
{
"_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,
"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,
"integrationId": null
}
Captured from the API examples test suite; ids and timestamps are normalized.

Behavior:

  • Pure rename (filename only), pure move (parentId only), 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 derived contentModifiedAt

Error Responses:

  • 404 Not Found - App or file doesn't exist
  • 403 Forbidden - User lacks write permission
  • 409 Conflict - A sibling with the new filename already exists under parentId
  • 400 Bad Request - Invalid filename, unknown parentId, parentId references 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:

ParameterTypeDescription
idstringApp UUID or natural ID

Payload:

Multipart form data with file chunks. Refer to the chunked upload implementation for specific field requirements.

Response:

Returns upload status and file metadata.

Use Case:

This endpoint is used by the file upload UI for:

  • Large binary files
  • Progress tracking
  • Resume capability

Error Responses:

  • 404 Not Found - App doesn't exist or user lacks access
  • 403 Forbidden - User lacks write permission

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}`);
}