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.
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" }
}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.
| Scope | What it grants |
|---|---|
| data: read | Read the workspace brain: memories, tasks, CRM, pages, files. Reaches no write tool at all. |
| data: read_write | Read and write the same surfaces. |
| identity | See the viewer's name. Your app can already tell viewers apart without this. |
| net | Fetch from specific origins. Bare https hosts only, no path and no wildcard. Empty by default. |
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.
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.
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.