BetterClaw
Apps

Building an app

The manifest, the bc API, and the rules a BetterClaw app runs under.

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.

CapabilityUnlocks
sqlbc.sql.* — the app's own SQLite database
filesbc.files.* — read and write files in the app folder
scriptbc.script.run() — run a script from the manifest
chatbc.chat.send() — message the agent
secretsbc.secrets.get() — read keys listed in secrets
toolsbc.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:

VariableMeaning
BC_APP_SLUGThe app's slug
BC_APP_DIRAbsolute path to the app folder
BC_HUB_URLBase URL for the same API bc uses
BC_APP_TOKENBearer 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

  1. Ask an agent to build the app. It writes the files into apps/<slug>/.
  2. In Apps, choose Load from agent and enter the slug.
  3. 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 seeUsually means
App is not installedThe folder or app.json is missing, or the slug and folder name disagree
did not declare the X capabilityAdd 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 configuredAdd the credential under Secrets in the workspace
A blank panelA JavaScript error — check the browser console