Custom apps

Build a small web app that renders full-page inside a workspace and reads its brain. No server, no build step, no hosting to set up: a manifest, an HTML file, and whatever assets you need.

01

What an app is

A static bundle a workspace installs: from a GitHub repository it re-syncs every 15 minutes, written by the assistant in chat, or imported from a zip that another workspace exported. All three paths produce the same artifact and go through the same approval, and every app can be exported as a zip for sharing.

The bundle

brian-app.json plus an entry HTML file plus optional assets. Files an app cannot serve, like a README or CI config, are ignored, so an ordinary repository works as-is.

Files
100
Total size
5 MB
Per file
2 MB
{
  "manifestVersion": 1,
  "name": "Pipeline board",
  "description": "This quarter's deals, by stage",
  "icon": "Users",
  "entry": "index.html",
  "scopes": { "data": "read" }
}
02

Scopes are the ask

The scopes block is what a workspace owner or admin reads before deciding to run your code against their company's data. Ask for the least that works: a wider request is a harder approval to get.

What each scope grants
ScopeWhat it grants
data: readRead the workspace brain: memories, tasks, CRM, pages, files. Reaches no write tool at all.
data: read_writeRead and write the same surfaces.
identitySee the viewer's name. Your app can already tell viewers apart without this.
netFetch from specific origins. Bare https hosts only, no path and no wildcard. Empty by default.
03

Approval, and what voids it

An app does not appear until an owner or admin approves the access it asks for. If a later version asks for more, the app leaves Home until someone approves the new list. A push cannot widen what your code can reach.

How an app reaches the brain
Your appisolated frameno cookiesno storageno page accessub:readyHostscoped tokenshort-livedBearer tokenWorkspace brainMCP toolsgated by scopefiltered per vieweradmin approvalNo approval, no token. No token, no data.
04

The sandbox

Your app runs in an isolated frame with no cookies, no browser storage, and no access to the page around it. That isolation is why a workspace can run your code at all, and it is why data, storage, and navigation all go through the bridge instead.

Talking to the brain

Ask the host for a token, then call the brain's MCP endpoint with it. The token is short-lived and refreshed for you; results are filtered to what the person viewing the app is allowed to see.

const ctx = await new Promise((resolve) => {
  window.addEventListener("message", (e) => {
    if (e.data?.type === "ub:token") resolve(e.data);
  });
  parent.postMessage({ type: "ub:ready" }, "*");
});

await fetch(`${ctx.apiOrigin}/api/brain/mcp`, {
  method: "POST",
  headers: { Authorization: `Bearer ${ctx.token}` },
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call",
    params: { name: "searchBrain", arguments: { query: "open deals" } } }),
});

There is no browser storage, so apps remember things through a key-value endpoint instead, either per viewer or shared across the workspace, up to 256 KB each.

05

Getting started

Start from the template repository, run the linter locally and in CI, push, then add it under Studio, Mini apps, Custom. The linter runs the same checks the importer does, so a clean run means a clean import.

Building one on someone's behalf? The full machine-readable contract lives in agent-docs.