Assets API
The Assets API manages the workspace's logical assets/ namespace. It supports saving, importing, reading, and deleting assets used in drafts and messages; the cloud-primary workspace is not a plaintext local checkout.
Cloud wire endpoints
The cloud worker exposes the same / family for encrypted workspace assets. Every request must include X-Workspace-Id (or the equivalent workspaceId query parameter). encryptedFilename and :token are opaque base64url tokens; the server never interprets a plaintext path, filename, MIME type, or asset contents.
Cloud asset mutations do not require an active subscription. Free users remain subject to the storage quota enforced by the upload layer, while listing, reading, renaming, moving, and deleting remain available when an account is over quota.
PAT authorization is deliberately narrower than the interactive filer:
| Route family | Narrow PAT scope |
|---|---|
GET /, GET /, GET /api/assets/:token | assets:read |
POST /api/assets | assets:write |
PATCH /api/assets/:token, DELETE /api/assets/:token, POST / | none — interactive auth or full PAT only |
All methods keep workspace ownership and PAT workspace-binding checks. Asset routes never accept X-Key-Session; clients derive non-extractable asset keys locally. assets:read also permits the Automation API's encryption-salt metadata read without the document subscription gate. assets:write alone does not permit that metadata read.
GET /api/assets/list
List opaque asset metadata in newest-uploaded-first order. The response is a JSON array of { encryptedFilename, sizeBytes, uploadedAt } rows; clients decrypt the filename and subtract the fixed encrypted envelope overhead.
GET /api/assets/usage
Return account-owner-scoped storage usage. usedBytes sums the counters for every workspace owned by the account. The storage-specific plan may be developer without widening the general subscription plan type.
{
"usedBytes": 1048576,
"quotaBytes": 52428800,
"plan": "free",
"addonBytes": 0,
"locked": false
}Free storage is exactly 50 MiB. Pro and in-grace trials receive 10 GiB plus their storage add-on. Allowlisted developer accounts have quotaBytes: null. locked is true only when finite usage is already above the effective quota.
POST /api/assets
Upload an encrypted envelope using { encryptedFilename, data }, where data is standard base64. The server validates the opaque token and the 61-byte minimum / 25 MiB plaintext-size-derived envelope bounds, stores a random-keyed R2 object, and returns the token as a JSON string with 201. Clients may send an Idempotency-Key containing 1–128 visible ASCII characters. It is scoped to the workspace and retained on the asset row for 24 hours; replaying the same key and token returns the original token without another object or counter increment, while reusing it for another token returns 409. A token conflict also returns 409 so the client can choose its next collision suffix.
Quota reservation and row insertion commit together after the R2 put. An upload that would exceed the account quota deletes its just-written object best-effort and returns 507:
{
"code": "STORAGE_QUOTA_EXCEEDED",
"usedBytes": 52428800,
"quotaBytes": 52428800,
"requestedBytes": 1
}Accounting uses plaintext-equivalent bytes: max(0, size_bytes - 61). Stored user_assets.size_bytes and list/read wire metadata remain the encrypted envelope length.
GET /api/assets/:token
Read an encrypted envelope by opaque token. The response is base64 text and includes X-Asset-Size-Bytes; MIME and filename interpretation remain client-side.
PATCH /api/assets/:token
Re-key an existing encrypted asset without re-uploading it. The r2_key, byte size, upload timestamp, and accounting values are unchanged.
This route remains interactive/full. Neither narrow asset scope authorizes rename or move.
{ "newEncryptedFilename": "<base64url-opaque-token>" }The response is { "encryptedFilename": "<new-token>" }. A missing source returns 404; a destination token already in the workspace returns 409. Both the source and destination tokens use the same validation as upload and download routes.
POST /api/assets/batch-delete
Delete multiple opaque tokens in one transactional D1 batch. The request is validated in full before any mutation, so invalid or duplicate tokens return 400 and delete nothing. R2 objects are cleaned up after the D1 commit.
This route and single-token DELETE remain interactive/full; the narrow MCP asset family cannot call or emulate them.
{ "tokens": ["<token-a>", "<token-b>"] }The response preserves request order. A valid token that is already absent is an idempotent success with deleted: false.
{
"results": [
{ "token": "<token-a>", "ok": true, "deleted": true },
{ "token": "<missing-token>", "ok": true, "deleted": false }
]
}The single-token DELETE /api/assets/:token route has the same idempotent behavior and returns { "ok": true }. Logical D1 deletion and the workspace counter decrement are one transaction; R2 cleanup remains best-effort after that commit.
Operators can audit or repair drift with workers/. It reports stored and recomputed values per workspace and is dry-run by default; pass --apply only after reviewing the report.
Narrow MCP asset boundary
The MCP inventory is exactly list_assets, get_asset_usage, download_asset, upload_asset, and create_asset_folder.
upload_assettakes an explicit absolute local source path and a caller-stable idempotency key. It rejects directories, symlinks, sockets, devices, and any other non-regular file; it enforces the 25 MiB plaintext cap before and during a bounded read.download_assettakes an explicit absolute destination, exclusively creates a new file, and never overwrites or follows a model-supplied destination symlink. A failure removes only the partial file that call created.Binary bytes and base64 envelopes never appear in MCP text or structured results. The local stdio process performs encryption/decryption and returns metadata plus the final local path.
Quota and soft-lock results remain authoritative. Over-quota accounts may still list and download; creation can fail with structured 507 details.
Assets have no immutable revision, versions, history, checkpoint coverage, document change cursor, or undo. There is no narrow move, rename, delete, recursive delete, replace, or delete-and-recreate tool. Asset replace must not be emulated as DELETE + POST, and neither asset nor document moves rewrite Markdown references.
Asset-change wake-up
Every committed upload/folder-marker creation and every interactive rename or delete emits exactly one room-scoped message whose complete payload is:
{ "type": "assets-changed" }It carries no plaintext path, opaque token, body, count, or document cursor. Idempotency replay, already-absent delete, empty batch, no-op, validation, conflict, quota failure, and other failed mutation emit nothing. Open filers only use the event to refetch listing and usage; it is neither ordered nor replayable.
Native bridge structures
The local Tauri commands expose the same path-aware bridge shapes as cloud workspaces. Paths are canonical POSIX-relative paths. The empty string is used only as the explicit root directory sentinel; file and folder entries are always non-empty.
interface AssetEntry {
path: string; // e.g. "docs/2026/report.pdf"
filename: string; // basename of path, e.g. "report.pdf"
sizeBytes: number; // plaintext/local file size
modifiedAt: string; // ISO 8601 timestamp (RFC 3339)
kind: 'image' | 'file';
mimeType?: string;
absolutePath?: string; // populated by the local/Tauri adapter only
}
interface AssetFolder {
path: string; // never slash-terminated
name: string; // basename of path
}
interface AssetListing {
files: AssetEntry[];
folders: AssetFolder[]; // root is implicit and omitted
}
interface AssetUsage {
usedBytes: number;
quotaBytes: number | null;
plan: 'free' | 'pro' | 'developer';
addonBytes: number;
locked: boolean;
}
interface AssetBatchResult {
total: number;
succeeded: Array<{ path: string; targetPath?: string }>;
failed: Array<{
path: string;
targetPath?: string;
code: 'NOT_FOUND' | 'CONFLICT' | 'INVALID_PATH' | 'FOLDER_NOT_EMPTY' | 'IO_ERROR';
message: string;
}>;
}New write destinations normalize each path segment and enforce the shared 255-byte UTF-8 limit. Leading, trailing, or repeated / separators, backslashes, ./.., absolute/drive/UNC paths, and the reserved .zudofolder segment are rejected. Existing listing paths used for lookup are validated without being rewritten, so a lookup can never silently address a different file. Local folders are real directories; cloud-only marker files are never materialized on disk.
Native commands
Listing, storage, and basic file operations
assets_list recursively lists the local <project_root>/assets/ tree and returns an AssetListing. Files remain newest-modified-first; folders are deduplicated and sorted by canonical path. A missing assets directory returns empty files and folders arrays.
const listing = await invoke<AssetListing>('assets_list');
const usage = await invoke<AssetUsage>('assets_get_usage');Local storage is unmetered, so assets_get_usage reports the actual file-byte sum with plan: 'developer', quotaBytes: null, addonBytes: 0, and locked: false.
assets_save_file writes standard-base64 data at a normalized relative path. assets_import_file copies an absolute local source into the optional target directory. Both return the actual canonical path and choose a collision suffix without overwriting an existing file or folder.
const savedPath = await invoke<string>('assets_save_file', {
filename: 'docs/report.pdf',
data: 'JVBERi0xLjQK...',
});
const importedPath = await invoke<string>('assets_import_file', {
sourcePath: '/Users/me/Desktop/photo.jpg',
targetDir: 'photos/2026',
});assets_read_file returns standard-base64 content or null when the exact path is missing. assets_delete_file is idempotent and returns a boolean. Neither command strips path components: traversal and malformed paths are rejected instead.
const data = await invoke<string | null>('assets_read_file', {
filename: 'docs/report.pdf',
});
const deleted = await invoke<boolean>('assets_delete_file', {
filename: 'docs/report.pdf',
});Folder and rename operations
assets_create_dir is mkdir-p and idempotent. assets_delete_dir supports empty-only and recursive deletion. assets_rename moves or renames either a file or a complete folder subtree. File/folder names share one namespace; these commands never overwrite an existing destination.
await invoke<void>('assets_create_dir', { path: 'docs/2026' });
const renamed = await invoke<AssetBatchResult>('assets_rename', {
fromPath: 'docs/2026',
toPath: 'archive/2026',
});
const removed = await invoke<AssetBatchResult>('assets_delete_dir', {
path: 'archive',
recursive: true,
});Batch results report every completed or failed path. A non-recursive delete of a non-empty folder returns FOLDER_NOT_EMPTY; deleting a missing folder is an idempotent success. Rename/move does not rewrite asset references in notes.
Native downloads
assets_download_file opens the native save dialog and writes already-read base64 data. It returns false when the user cancels.
const saved = await invoke<boolean>('assets_download_file', {
filename: 'report.pdf',
data: 'JVBERi0xLjQK...',
});Large ZIP downloads use an atomic streaming sink. download_sink_open creates a temporary sibling file after destination selection and returns its opaque id, or null on cancellation. download_sink_write accepts raw byte arrays. download_sink_close flushes and atomically replaces the destination; download_sink_abort discards an unfinished sink and its temporary file.
const sinkId = await invoke<string | null>('download_sink_open', {
suggestedName: 'assets.zip',
});
if (sinkId) {
await invoke<void>('download_sink_write', { sinkId, bytes: [80, 75, 3, 4] });
await invoke<void>('download_sink_close', { sinkId });
// On cancellation/error before close:
// await invoke<void>('download_sink_abort', { sinkId });
}assets_download_many opens a native folder picker and materializes a set of already-read files while preserving their relative subdirectories. Existing destination files are reported as CONFLICT and never overwritten.
const result = await invoke<AssetBatchResult>('assets_download_many', {
entries: [
{ path: 'docs/report.txt', bytes: [104, 101, 108, 108, 111] },
],
});Collision suffixing and directory structure
Saving or importing docs/ first tries the requested path, then docs/ through docs/, and finally a millisecond timestamp suffix. Only the final basename segment changes. A long stem is truncated at a Unicode code-point boundary when necessary to retain the parent, suffix, extension, and 255-byte path limit.
<project_root>/
assets/
docs/
report.pdf
photos/
2026/
photo.jpgThe assets/ directory and requested parent directories are created on the first save or import.