Template format
What a Busabase template directory contains — the root SKILL.md, the template object in busabase.json, .busabaseignore, the validator's rules, and the ownership stamp that lets two installers recognise the same app.
A template is the busabase-package@1 format plus a small increment that lives entirely outside content/:
busa-email/
├── SKILL.md ← the manual an agent reads
├── references/ ← carried into the Skill node
├── agents/
├── scripts/
├── assets/screenshots/ ← Template Center card art (never installed)
├── busabase.json ← manifest + a `template` object
└── content/ ← byte-identical to a plain package
├── settings/base.json
├── settings/records.ndjson
└── busa-email-app/ ← an AirApp, with a .busabaseignoreNothing under content/ changes — the package format is unchanged, which is what makes this a multiplexed format rather than a second one. The same directory is a valid Agent Skill (an agent can install it with npx skills add) and a valid Busabase package (busabase-cli install can install it), and neither view has to know about the other.
The root SKILL.md
The entry file is named SKILL.md on purpose: it becomes exactly a Skill node on install, inside the target folder, so the manual travels with the resources instead of staying on the publisher's disk.
Three sidecar directories are carried into that node alongside it:
| Directory | Why |
|---|---|
references/ | Field references, schemas, examples — the material SKILL.md links to |
agents/ | Sub-agent definitions the skill ships |
scripts/ | The skill's own scripts, e.g. setup.mjs |
scripts/ is stored, never executed by Busabase. It is carried so that an agent pulling the skill back out of a space gets a working copy — dropping it would make the round trip lossy. The safety property comes from nothing in Busabase running it, not from refusing to store it.
Frontmatter
---
name: busa-email
description: An inbox triage desk — drafts replies, you approve them.
metadata:
busabase:
template: true
resources:
- settings
- reviews
---| Key | Meaning |
|---|---|
name | Required, and must equal busabase.json's name — they are the same app |
description | Shown wherever the skill is listed |
metadata.busabase.template | The opt-in. true means "publish this as a template" |
metadata.busabase.resources | Base slugs the manual talks about. Each must exist under content/ |
metadata.busabase.folderSlug, risk | Declarative — for readers and future tooling; nothing in install reads them today |
template: true is an explicit opt-in, not an inference from directory shape. Publishing a template means accepting that installers will run its AirApp code and feed its SKILL.md to their agent; that deserves a deliberate flag.
resources is validated rather than trusted: an agent told about a table that does not exist will write to the wrong one.
The template object in busabase.json
{
"format": "busabase-package@1",
"name": "busa-email",
"description": "An inbox triage desk.",
"version": "1.2.0",
"template": {
"category": "email",
"tags": ["inbox", "triage"],
"screenshots": ["assets/screenshots/overview.webp"],
"agentPrompts": [
"Triage today's inbox and draft replies for the ones that need one."
],
"airapp": "busa-email-app",
"schemaVersion": 1,
"secrets": [
{ "key": "BUSA_EMAIL_IMAP_PASSWORD_PRIMARY", "description": "Mailbox password", "required": true }
],
"requires": { "airapp": true }
}
}| Field | Default | What it does |
|---|---|---|
category | required | The Template Center category shown on the card |
tags | [] | Searchable in the gallery |
screenshots | [] | Package-relative paths. Resolved to URLs server-side, so a card can never point at a different ref than the one it installs |
agentPrompts | [] | Ready-made prompts shown on the detail page — the difference between "a folder of tables" and something usable |
airapp | — | Single-AirApp shorthand. Mutually exclusive with airapps |
airapps | — | { slug, role, label }[]. Roles: primary, admin, public, tool; exactly one must be primary |
schemaVersion | 1 | Bumped when the declared resource shape changes. Part of the ownership stamp, so both installers must agree on it |
vaultNamespace | — | Which Vault namespace the app's secrets live in |
secrets | [] | { key, description, required }[] — declared, never created |
requires.airapp | — | true makes "no AirApp in content/" an error rather than a warning |
The package format has no slot for secret values and must not grow one — the same "you cannot leak what the format cannot express" rule the whole format rests on. secrets tells a user which keys to put in the Vault themselves; install neither creates them nor currently prompts for them.
Which AirApp is "the app"
Only primary has mechanical meaning; the other roles are labels for humans and for an agent's benefit ("start the admin panel"). Resolution order:
template.airappif declared — it must match an AirApp undercontent/.- Otherwise
template.airapps, which must declare exactly oneprimaryand reference only AirApps that exist. - Otherwise, if
content/holds exactly one AirApp, that's it. - Otherwise: an error. Ambiguity is never resolved by taking the first alphabetically — a template that silently opens the wrong app is worse than one that refuses to publish.
.busabaseignore
An AirApp node is the project — Busabase starts it with npm run dev against exactly what the node contains — so content/<airapp>/ has to be both the deployable artefact and the directory a developer works in. content/<airapp>/.busabaseignore is what lets one directory be both: the repo keeps test/, lockfiles and coverage; the node receives only what has to run.
It is a deliberately small subset of gitignore syntax, stated so authors are never surprised by a silent near-miss:
| Pattern | Meaning |
|---|---|
# comment, blank line | Ignored |
test/ | That directory and everything under it |
coverage | A file or directory with this name, at any depth |
/build | Anchored to the AirApp root only |
*.log | * matches within one path segment |
docs/**/*.tmp | ** matches across segments |
!keep.log | Negation — re-includes a path an earlier rule excluded |
Last matching rule wins, exactly like gitignore. Not supported: character classes ([a-z]) and escaped literals (\#) — a pattern using them is matched literally rather than silently reinterpreted.
package.json, and the entry it declares, can never be ignored. That is a hard error rather than a warning, because the resulting AirApp would install fine and then fail to boot — the failure mode hardest to attribute.
Screenshots
assets/screenshots/ is card art for the Template Center. These files are not nodes and are never installed — they exist so the gallery has something to show. Declare them in template.screenshots with package-relative paths; the server turns them into URLs against the same repo and ref the card installs from.
A missing or renamed file degrades to the placeholder rather than a broken-image icon: the card is still useful without it.
Sample records with required relations
Relation values in records.ndjson are package record keys, resolved within the relation field's
targetBaseSlug. The installer creates required-relation targets first and replaces those keys
with canonical record ids before it creates each dependent row. Optional relations are normalized
after every sample row has an id.
That makes acyclic dependencies such as Company → Contact → Activity installable without weakening the Base schema. It also gives three mistakes a clear preview-time failure, before any workspace resource is created:
- a required relation is empty;
- its key does not exist in the declared target Base;
- required relations form a cycle, so no valid first row exists.
Record keys are scoped to their Base. Two Bases may both contain a key such as default; the
relation field's targetBaseSlug is what makes the reference unambiguous.
What the validator checks
One function decides whether a package is a template, and all four consumers share it: the catalog build, busabase-cli install, the dashboard's install preview, and an agent checking its own output. A skill that shows up as a card but installs as a plain package (or the reverse) is a trust bug, not a cosmetic one.
It is purely static — no AirApp is started, no script is run, no server is contacted.
Errors — any one of these means "not a template"
| Check | Why it's fatal |
|---|---|
No root SKILL.md | It's simply a plain package |
| Frontmatter isn't a valid skill declaration | Nothing downstream can read it |
metadata.busabase.template isn't true | No opt-in, no publication |
busabase.json has no template object | At minimum category is required |
SKILL.md's name ≠ manifest name | They are the same app and must agree |
A resources entry has no matching Base under content/ | An agent would write to the wrong table |
| The primary AirApp can't be resolved | Install must not guess which app to open |
An AirApp's package.json is invalid JSON, or has no dev script | It would install fine and never boot |
requires.airapp is true with no AirApp in content/ | The manifest contradicts the contents |
| A Base carries more than 50 sample records | A template seeds a demo; it does not ship a dataset |
Warnings — it still publishes, the card is just honest
| Warning | Consequence |
|---|---|
| No AirApp | Data-only template: users see tables, not an app |
| No sample records | The app is empty on first open |
| No screenshots | The card uses a placeholder |
No agentPrompts | The detail page has no suggested prompts |
Failing to be a template is never a reason to refuse a valid package. A package that fails these checks still installs — as a plain busabase-package@1, exactly as it did before templates existed.
Ownership stamps
This is the mechanism that makes "two doors, one app" work. Two independent code paths create the same resources — the installer in busabase-package, and a skill's own setup.mjs through busabase-sdk — and they recognise each other's work only by the shape of a stamp written into node metadata.
| Node | Stamp |
|---|---|
| Every resource (Base, Drive, AirApp, …) | appId, resourceKey, schemaVersion |
| The app's root Folder | The same triple with resourceKey: "app-root", plus version, source (repo / ref / subdir) and installedAt |
| The Skill node lifted from the package root | appId and isTemplateSkill: true |
Two details carry weight:
resourceKeyis the package's own slug, not the installed one. A Base the author calledsettingsinstalls asbusa-email-settings, and the stamp still sayssettings. That's how the app finds its own tables by a stable handle regardless of which folder it was installed into.- The SDK treats an unstamped — or differently stamped — node as someone else's and raises
SETUP_CONFLICTrather than touching it. So if the two writers ever drifted, a user who installed from the Template Center and then ran the same skill in their shell would get a hard conflict on their own data. That's why the stamp is defined once, in the contract package, and neither side may re-declare it locally; the SDK's copy is checked against the contract's at compile time.
The Skill node's isTemplateSkill marker is needed in both directions: export --template lifts exactly that node back out to the package root (rather than writing it under content/, which would create the duplicate the format exists to avoid), and the agent wiring lists exactly these nodes as "app manuals available in this space".
Related
- Templates — installing and using them
- Publish a template —
export --templateand the catalog - Install & Export (packages) — the base format
- Node types — Skill and AirApp nodes
Templates
Install a complete app — tables, an interface, and the manual an agent reads before it touches your data — from the Template Center, the CLI, or your agent's own shell.
Publish a template
Turn a folder you already built into an installable app — busabase-cli export --template, the SKILL.md draft, the catalog index, and how to point a self-hosted server at your own gallery.