Skip to content
Charming Docs
Esc
navigateopen⌘Jpreview
On this page

Data storage

Every app comes with storage your agent can read and write: lists, settings, totals, even files.

What your app can store

Values. Lists, settings, totals, notes: the everyday things an app needs to remember. Whatever your app saves survives between visits and across code updates.

Files. Your app can keep images, PDFs, audio, video, and other files, and show them right in its interface. Charming apps render inside AI chats like Claude and ChatGPT, so your files show up there too. Each app holds up to 50 files, 10MB each, 100MB combined. Files stick around the same way your app’s other data does. Contact us if you need more.

Technical

The app you build is its own UI for its own storage: it reads and writes key-value data (each JSON-serializable value keyed by a string you choose) by calling its own backend operations (window.buildy.api(op) from the frontend, env.storage from the handler behind it), and files the same way through window.buildy.assets or env.assets. An agent can also add a file directly with the upload_asset MCP tool, no app code required.

Copy this prompt for your agent

Add persistent storage to my Charming app. Read
https://charm.ing/docs/capabilities/data-storage.md first. Work out
from the app what it should save, values or files; if it isn't
obvious, ask me.
If you haven't built a Charming app before, read
https://charm.ing/docs/build-mcp.md (or build-http.md for the HTTP API)
for the app skeleton first.

How an agent performs this job

Your agent declares buildy:storage/kv@1.0 in the app’s manifest.capabilities.imports when it creates or updates the app (create_app / update_app over MCP, or POST /app / PUT /app/<id> over HTTP). Inside a route handler, it reads env.storage from the handler’s context and calls .get, .put, .delete, or .list. To read or change stored data without touching the app’s code, an agent (including a different one from whichever built it) calls query_app for a read-only operation or mutate_app for one that writes.

Files work the same way, plus a shortcut that skips the app’s code entirely. Two paths add or use a file:

  • Without touching the app’s code. An agent calls the upload_asset MCP tool with the app id, a key, and exactly one of text (UTF-8), sourceUrl (the server fetches it), or dataBase64 (small binaries only). This works whether or not the app declared the blob import, because it writes directly rather than going through env.assets.
  • From inside the app. The app declares buildy:storage/blob@1.0 in manifest.capabilities.imports when it’s created or updated. A route handler then reads env.assets from its context and calls .get, .put, .delete, .list, or .url. The frontend can call the matching window.buildy.assets methods (.getUrl, .load, .upload, .list, .delete) directly, without a handler in between.

The contract

env.storage is only defined if the app declared the buildy:storage/kv@1.0 import. Its shape:

env.storage.get(key); // Promise<unknown | undefined>
env.storage.put(key, value); // Promise<void> - value must be JSON-serializable
env.storage.delete(key); // Promise<boolean>
env.storage.list(); // Promise<string[]>

Storage is per-app and per-key, not a shared database across apps.

env.assets is only defined if the app declared the buildy:storage/blob@1.0 import. Its shape:

env.assets.get(key); // Promise<{ bytes: Uint8Array; contentType: string } | undefined>
env.assets.put(key, bytes, { contentType }); // Promise<void>
env.assets.delete(key); // Promise<boolean>
env.assets.list(); // Promise<Array<{ key: string; contentType: string; sizeBytes: number }>>
env.assets.url(key); // string - same-origin serve URL

env.assets.url(key) returns a same-origin URL your app’s UI can drop into an <img src> or <a href>, or your app’s backend can read the raw bytes itself with .get(key). window.buildy.assets mirrors it on the frontend: getUrl(key) returns the same-origin URL synchronously; load(key) fetches the file and resolves a data: URL instead, for hosts like Claude and ChatGPT whose own CSP blocks the cross-origin getUrl result; upload(file, opts?) posts a File or Blob and resolves { key, url }; list() and delete(key) round out the set.

Limits, access, and deletion

  • Per-value limit. Each value you put is capped at 10MB, measured as its JSON-serialized UTF-8 byte length. That cap applies to any one value; your keys as a group aren’t capped separately.
  • Serialization. put stores JSON-compatible data: objects, arrays, strings, numbers, booleans, and null. A value that can’t be JSON-serialized (a circular reference, a BigInt) fails the write.
  • Key order. list() returns your keys but doesn’t guarantee an order; sort them yourself if order matters.
  • Storage failures. A put that exceeds the per-value cap fails with error.kind: "storage_quota_exceeded". The recovery is to shrink the value, for example by trimming old entries, and retry.
  • Per-file limit. Each file is capped at 10MB, measured as raw bytes, not JSON-encoded like key-value storage.
  • Per-app file totals. Up to 50 files per app, 100MB combined. A put that would cross either cap fails and leaves what’s already stored untouched.
  • File keys. URL-safe: letters, digits, ., _, -, up to 128 characters, no leading dot, no .. sequences.
  • File content type. Defaults to text/plain for a text payload, the sniffed response header for sourceUrl (falling back to application/octet-stream), or the browser File’s own type for window.buildy.assets.upload. text/html, application/xhtml+xml, and image/svg+xml are always rejected: files serve back same-origin, so any of those would run as active content if opened directly.
  • upload_asset input caps. sourceUrl is fetched server-side and cut off the moment it crosses the 10MB file cap, even if the remote server sends a missing or lying Content-Length. dataBase64 decodes to at most 64KB and exists only for small binaries. Keep the encoded string under ~16KB; a larger base64 tool-call argument can stall in some MCP clients before it ever reaches the server.
  • File failure behavior. A rejected write carries an error.kind: asset_too_large, asset_count_exceeded, or asset_quota_exceeded for a cap, asset_invalid_key or asset_invalid_content_type for bad input. The recovery is to shrink, remove, or rename and retry.
  • Access. An app’s storage and files belong to the app, not to any one visitor. Apps are single-tenant: one app is one shared dataset, owned by whoever created it (or claimed it). The owner, and anyone they invite as a collaborator or end-user, can read and write storage and files through the app’s operations or upload_asset. A read-only viewer can read but not write.
  • Deletion. Deleting an app doesn’t purge key-value storage or secrets by default; delete_app takes a purge_storage: true flag to wipe those along with the app entry. Files are different: deleting an app always removes its stored files too, whether or not purge_storage is set.

Was this page helpful?