Simon Willison shipped datasette-apps 0.1a2 on June 18 — a Datasette plugin that lets any authenticated instance host self-contained HTML+JavaScript applications inside a tightly sandboxed iframe. The apps can issue read-only SQL against live databases or run pre-approved write queries via stored procedures, without accessing cookies, the parent DOM, or exfiltrating data to external servers. The project started as Willison's attempt to build a Claude Artifacts mechanism for Datasette Agent, then grew into a first-class primitive after he realized the sandboxing pattern was broadly useful for running LLM-generated code against sensitive data.

The security architecture is a two-layer stack. Layer one is the iframe attribute: `<iframe sandbox="allow-scripts allow-forms" srcdoc="...">`. Critically, `allow-same-origin` is absent — the sandboxed page cannot read the parent DOM, access cookies, or touch localStorage. Layer two seals the network egress hole: on page load, Datasette injects a `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:;">` into the iframe's srcdoc. Once parsed, the policy is immutable — malicious JavaScript cannot update or remove it. External HTTP requests are blocked at the browser level.

Three-layer security model: iframe sandbox attributes, immutable CSP meta tag, and MessageChannel transport bridge.
FIG. 02 Three-layer security model: iframe sandbox attributes, immutable CSP meta tag, and MessageChannel transport bridge.

The communication channel between app and database received one security upgrade before launch. The first implementation used `postMessage()`: the iframe sends a SQL request, the parent verifies the target database is on the allow-list, executes the query, and returns results. GPT-5.5 flagged that postMessage alone can be abused if the iframe somehow loads additional code from an untrusted origin. Willison ported to `MessageChannel()` as a defense-in-depth measure. The practical difference: when a MessageChannel port is live and the frame navigates to an untrusted page, the channel closes automatically, preventing late-arriving commands from executing on a page the attacker now controls. The API surface exposed to app JavaScript is two helpers — `datasette.query(database, sql, params)` for read access and `datasette.storedQuery(database, query, params)` for allow-listed writes.

Apps are identified by lowercase monotonic ULIDs; every edit lands as a new row in an `app_revisions` table, so rollback is a database query. The iframe bridge intercepts and forwards JavaScript errors, unhandled promise rejections, CSP violations, console.error() calls, and failed fetch attempts — surfacing them in an expandable error panel above the iframe and a log panel below. LLM-generated apps break in ways that are otherwise invisible to the developer. History management APIs (`pushState`, `replaceState`, `back`, `forward`, `go`) are replaced with no-ops inside the sandbox to prevent browser navigation errors from apps that try to manage URL state.

Permission controls are granular. The plugin registers six Datasette permissions: `create-app`, `view-app`, `edit-app`, `delete-app`, `manage-app-access`, and `apps-set-csp`. The last one is the sharp edge: it controls who can add arbitrary `https://` origins to an app's CSP allow-list, the only path by which data could flow out of the sandbox to an external server. Nobody holds `apps-set-csp` by default. Localhost origins cannot be added under any configuration, closing an obvious SSRF-style route to internal services.

The demo runs at agent.datasette.io on Datasette 1.0a34 and requires a GitHub login. A self-hosted instance can be spun up with a uv one-liner. Alex Garcia contributed the companion permissions plugin that enables fine-grained multi-user access control across a shared instance. The plugin ecosystem now includes LLM integration (`datasette-llm`), file uploads (`datasette-files`), AI enrichments (`datasette-enrichments-llm`), and natural language query (`datasette-agent`); Apps adds the custom-UI layer.

The architecture — `iframe sandbox` (sans `allow-same-origin`) plus an immutable CSP meta tag plus a MessageChannel transport plus allow-listed query endpoints — is a reproducible, browser-native stack for running untrusted LLM-generated code against sensitive relational data. The postMessage→MessageChannel migration is the instructive detail: the primary sandbox was already sufficient by most threat models, but the channel upgrade closes a residual risk at near-zero implementation cost. Teams building internal AI data tools on SQLite can replicate this pattern without waiting for Anthropic or Google to ship persistent storage behind their artifact sandboxes.