Busabase

Build an AirApp

Write an interface that opens inside a Busabase workspace — the project layout, Hono plus vanilla browser code, reading the injected runtime instead of the hostname, page budgets, and shipping it through review.

An AirApp is a small web app that opens inside a workspace and reads that workspace's live data. Node types explains what one is from a user's side. This page is for the person writing it.

Two things to have straight before the first file. An AirApp is a node type, like a Base or a Doc — it is something that exists in a workspace, not a thing you deploy at it. And it does not travel on its own: it ships inside a package, at content/<name>-app/, and gets installed along with the Bases it reads. If that distinction is new, What to build draws it in full.

Most of the rules below exist because something specific broke. Where that is the case, this page says what.

You do not have to write one by hand. Ask a connected agent to use the busabase-app-creator Skill and it will scaffold the layout below, wire the SDK, and submit the result for your review. Read this page anyway — you are the reviewer, and the rules here are what you are reviewing against.

The shape

Inside that package, the app itself is an ordinary Node project:

content/my-desk-app/
├── package.json        ← must declare a `dev` script
├── server.js           ← Hono; serves app/ and one runtime route
├── .busabaseignore     ← what stays in the repo and out of the node
├── app/
│   ├── index.html
│   ├── styles.css
│   └── js/
│       ├── app.js          ← the UI
│       ├── config.js       ← ids and budgets, no secrets
│       ├── runtime.js      ← where am I running
│       └── providers/      ← busabase-provider.js, demo-provider.js
└── scripts/check.mjs   ← the gate you run before submitting

Two constraints on package.json that are not style preferences:

{
  "scripts": {
    "dev": "node server.js",
    "start": "node server.js"
  },
  "dependencies": { "busabase-sdk": "0.30.1" }
}
  • dev must exist. Busabase boots an app with npm run dev. Without it the app installs cleanly and then never starts — the hardest kind of failure to attribute, because nothing looks wrong until someone clicks Run.
  • start may only start. No build step, no spawned subprocess. Whatever the app needs must already be in the files you submitted.
  • Pin busabase-sdk exactly. A range means the app someone installs is not the app you reviewed.

Hono and vanilla browser code

The browser half is plain HTML, CSS and JavaScript. No React, no Vite, no JSX, no framework build pipeline. The server half is Hono — or, if the app's own work is Python's, the stdlib server.py variant; the browser half is identical either way.

This is a real constraint rather than a taste: an AirApp's files are submitted, reviewed, merged and then run as-is. Anything that needs to be compiled first is something the reviewer did not actually read and the runtime cannot reproduce.

Talk to Busabase through the SDK

import { createBusabaseClient } from "busabase-sdk";

const client = createBusabaseClient({ baseUrl: window.location.origin });

Three things that will fail review:

  • A hard-coded Busabase URL in browser code. Use window.location.origin. A workspace can be served from a custom domain, a tunnel, or a sub-path — an absolute URL is right in exactly one deployment and wrong in the rest.
  • The /__busabase_api__/ prefix. That bridge is gone. The API is same-origin /api/v1.
  • Any credential in browser code or in a submitted file. No API key, Bearer token, OAuth token, session cookie, or secret. A deployed AirApp uses the viewer's ambient same-origin session and needs none. Secrets that a workflow genuinely needs live in Vault, which browser code cannot read — by design.

Where am I running?

An AirApp runs in more than one place: hosted by Busabase, or standalone under your own npm run dev. It must know which, because a standalone app has to show a connection gate and a hosted one must never do so.

Ask your own server, which reads the environment variable Busabase injects:

// server.js
import { describeBusabaseAirAppRuntime } from "busabase-sdk/airapp-node";

const airappRuntime = describeBusabaseAirAppRuntime();
app.get("/__airapp/runtime", (context) => context.json(airappRuntime));
// app/js/runtime.js — relative path, no leading slash
const response = await fetch("__airapp/runtime", { headers: { accept: "application/json" } });

Never infer the runtime from location.hostname or from iframe nesting. Both directions of that test are wrong: a Busabase-hosted AirApp can be served from localhost, and a standalone npm run dev can be reached over a signed dev tunnel.

Two details that look like fussiness and are not:

  • The fetch path is relative. Under the Local Node engine the app is proxied onto a sub-path of Busabase's own origin, so a leading slash resolves against Busabase's root and 404s.
  • hosted is decided by the variable being present, never by matching a list of known engine names. A local list of names is what broke 66 shipped apps when the engine local-node was renamed local: each one decided it was standalone while running inside a hosted preview, showed its own connection gate, and called /api/v1 with no credential.

There is a third answer besides hosted and standalone: unknown, when the runtime endpoint did not reply. Say so in the UI. Do not guess.

Give every read a budget

Every interactive page gets an explicit limit, and the ceiling is 50 records per Base:

// app/js/config.js
bases: [
  { key: "reviews", slug: "my-desk-reviews", readLimit: 50, fields: reviewFields },
],

Use server-side filters and sorts, run independent reads in parallel, keep nextCursor, and fetch one page per user action. Never hide a full scan behind loading, search, filtering, refresh, navigation, or opening a detail view. A full export is a separate, explicitly batched workflow — not something a click quietly triggers.

Config holds ids, never secrets

config.js is the app's own map of the workspace. It may contain exact materialized ids, procedure allowlists, limits, and schema versions. It may not contain values from Vault.

Two fields are load-bearing and easy to lose:

airApp: { name: "My Desk", slug: "my-desk-app", resourceKey: "my-desk-app" },
bases: [
  { key: "reviews", slug: "my-desk-reviews", /* ... */ },
],
  • resourceKey must equal the slug the package ships under. Install stamps nodes with it; a different value here and the app will not recognise its own node after a Template Center install.
  • Every Base needs a slug, not just a key. This one has bitten already: stripping a workspace's ids out of config.js before publishing took the slug with them, and only the app's own provisioning path broke. A Template Center install reads content/<base>/base.json and never opens that door, so the catalog check stayed green while the app failed at first run with slug: expected string, received undefined.

Writes go through review

An AirApp does not mutate canonical records. A requested write creates a Change Request and waits for a human. The app never reviews or merges its own proposal.

The first version of a new app should be read-only unless you asked for an action explicitly. That is not a training-wheels phase — it is what makes an app safe to install from a stranger.

Check it, then run it

cd content/my-desk-app
npm run check          # the app's own gate
npx busabase-cli check .. --only airapp

The app's scripts/check.mjs is a hard gate: it fails on hostname-based runtime detection, a missing runtime probe, an absolute asset path, a hard-coded Busabase URL, a readLimit outside 1–50, an unpinned SDK, a start that builds, Vault values in config, and unbounded loading or runtime Base discovery. Each of those is a rule that did not hold when it was only written down.

Passing both checks is a precondition, never the finish line. Install the package into a scratch space, merge it, and open the app. Static rules cannot tell you the interface is wrong, the copy is misleading, or the data on screen is stale.

On this page