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 submittingTwo constraints on package.json that are not style preferences:
{
"scripts": {
"dev": "node server.js",
"start": "node server.js"
},
"dependencies": { "busabase-sdk": "0.30.1" }
}devmust exist. Busabase boots an app withnpm 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.startmay only start. No build step, no spawned subprocess. Whatever the app needs must already be in the files you submitted.- Pin
busabase-sdkexactly. 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.
hostedis 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 enginelocal-nodewas renamedlocal: each one decided it was standalone while running inside a hosted preview, showed its own connection gate, and called/api/v1with 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", /* ... */ },
],resourceKeymust 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 akey. This one has bitten already: stripping a workspace's ids out ofconfig.jsbefore publishing took the slug with them, and only the app's own provisioning path broke. A Template Center install readscontent/<base>/base.jsonand never opens that door, so the catalog check stayed green while the app failed at first run withslug: 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 airappThe 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.
Related
- What to build — package, Skill, template, or AirApp
- Node types — what an AirApp is to the people using it
- Template format —
.busabaseignoreand the package it ships in - Change requests — the review flow an app's writes go through
- Vault secrets — where credentials live instead
What to build
Package, Agent Skill, template, or AirApp — the four things you can build for Busabase, how they nest, which one your idea actually is, and what each one costs you to ship.
Build a Platform Integration
Add Busabase to your product's third-party integrations catalog — OAuth 2.1 discovery, dynamic client registration, multi-tenant space targeting, and the approval-first tool contract, using Buda's own integration as the reference implementation.