Building an app
Building an app
An app is plain HTML, CSS and JavaScript — no build step, no npm, no framework required.
It runs in a locked-down frame and reaches everything through one global: bc.
The fastest way to start is to ask an agent. It knows this format and will scaffold the files for you. This page is the reference for what it writes, and for writing one yourself.
Layout
Apps live in your agent's workspace at apps/<slug>/:
apps/expense-tracker/
app.json the manifest — required
index.html the entry page — required
assets/ your CSS and JS
scripts/ optional scripts that run on the agent
data/ created for you — the app's database and files
app.db SQLite, created on first use
<slug> is lowercase letters, digits and hyphens, and must match the slug in app.json.
The manifest
{
"slug": "expense-tracker",
"name": "Expense Tracker",
"version": "0.1.0",
"description": "Track receipts your agent files for you",
"icon": "i-ph-receipt",
"entry": "index.html",
"capabilities": ["sql", "files", "chat"],
"secrets": [],
"toolkits": [],
"scripts": [{ "name": "import", "run": "node scripts/import.js", "timeoutSec": 60 }],
"cron": [
{
"key": "weekly-summary",
"label": "Weekly summary",
"default": "0 9 * * 1",
"prompt": "Read apps/expense-tracker/data/app.db and message me last week's total. If there were no expenses, say so in one line."
}
]
}
icon is any Phosphor icon name in i-ph-* form.
Capabilities
capabilities gates the bc API, and the user sees this list before installing. Declare
only what you use — calling into an undeclared capability fails.
| Capability | Unlocks |
|---|---|
sql | bc.sql.* — the app's own SQLite database |
files | bc.files.* — read and write files in the app folder |
script | bc.script.run() — run a script from the manifest |
chat | bc.chat.send() — message the agent |
secrets | bc.secrets.get() — read keys listed in secrets |
tools | bc.tools.* — call connected tools from toolkits |
secrets and toolkits are further allowlists: a key not in secrets cannot be read even
with the secrets capability, and the credential still has to exist in the workspace.
Schedules
Each cron entry becomes a recurring task, so it appears in Tasks where the user can
reschedule or pause it. The prompt is dispatched to the agent when it fires — write it as
an instruction, and say what to do when there is nothing to report, or the agent may invent
activity to fill the silence.
The plan's minimum cron interval applies. A schedule faster than the plan allows is created paused rather than failing the install.
The bc API
window.bc exists before your code runs. Every method returns a Promise.
// SQLite. Values always go through params — they are bound, never interpolated.
await bc.sql.exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT)');
const { changes, lastInsertRowid } = await bc.sql.exec(
'INSERT INTO items (name) VALUES (?)', [name]
);
const { rows, truncated } = await bc.sql.query('SELECT * FROM items WHERE name LIKE ?', [q + '%']);
// Files, relative to the app folder. Writes are confined to data/.
await bc.files.write('data/export.csv', csv);
const text = await bc.files.read('data/export.csv');
const bytes = await bc.files.readBytes('data/logo.png');
const entries = await bc.files.list('data');
await bc.files.delete('data/export.csv');
// Hand work to the agent. The reply appears in Chats.
const { chatId } = await bc.chat.send('Summarise this month from my expenses table');
// Scripts from the manifest, run on the agent.
const { exitCode, stdout, stderr } = await bc.script.run('import', { since: '2026-01-01' });
// Credentials and connected tools, if declared.
const key = await bc.secrets.get('OPENEXCHANGE_API_KEY');
const { tools } = await bc.tools.search('send an email');
await bc.tools.execute('GMAIL_SEND_EMAIL', { to, subject, body });
bc.app; // { slug, name, version, capabilities }
await bc.context(); // the above plus agentId and workspaceId
await bc.navigate('/chats');
Rules that matter
No network of your own. The frame runs at an opaque origin — no access to the page
around it, no cookies, no shared storage — and a Content-Security-Policy limits outbound
requests to the BetterClaw API alone. Everything goes through bc. Don't link a CDN or
call a third-party API from the page; both are blocked. If an app needs to reach an
external service, do it from a script (see below), where you control the request.
Reference assets with plain relative paths. <script src="assets/app.js"> and
<link href="assets/app.css"> are inlined when the app is served, which is what lets the
frame stay fully sandboxed. Small images and fonts are inlined too.
Keep the entry page under 2 MB assembled. Larger assets belong in data/ and should be
fetched with bc.files.read().
Never use innerHTML for data. Use textContent or build nodes with createElement.
Create tables with IF NOT EXISTS on every load. It keeps a first run and a reopen on
the same path, with no schema versioning to maintain.
Support both colour schemes with @media (prefers-color-scheme: dark).
Scripts
A script runs on the agent as a subprocess, in the app folder, with its arguments delivered as JSON on stdin. It gets a deliberately minimal environment — not the agent's, which holds model keys and hub credentials — plus:
| Variable | Meaning |
|---|---|
BC_APP_SLUG | The app's slug |
BC_APP_DIR | Absolute path to the app folder |
BC_HUB_URL | Base URL for the same API bc uses |
BC_APP_TOKEN | Bearer token scoped to this installation, valid for the run |
So a script can call the same endpoints the page does:
const args = JSON.parse(require('node:fs').readFileSync(0, 'utf-8'));
const res = await fetch(`${process.env.BC_HUB_URL}/app-api/sql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.BC_APP_TOKEN}`,
},
body: JSON.stringify({ op: 'exec', sql: 'INSERT INTO items (name) VALUES (?)', params: [args.name] }),
});
The authoring loop
- Ask an agent to build the app. It writes the files into
apps/<slug>/. - In Apps, choose Load from agent and enter the slug.
- Ask for changes in the chat, then hit Reload in the app panel.
An app registered this way stays at version dev and is re-read from the agent every time
it opens, so there is no reinstall step while you iterate.
Troubleshooting
| What you see | Usually means |
|---|---|
| App is not installed | The folder or app.json is missing, or the slug and folder name disagree |
| did not declare the X capability | Add it to capabilities |
| Apps may only write under data/ | A published app's own code is read-only; app data goes in data/ |
| No secret "K" is configured | Add the credential under Secrets in the workspace |
| A blank panel | A JavaScript error — check the browser console |