Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
8.6 KiB
Package Format
Armature packages are distributed as .pkg files -- ZIP archives with a standard internal structure.
ZIP Structure
my-package.pkg
├── manifest.json # required -- package metadata
├── js/ # browser-side JavaScript
│ └── index.js
├── css/ # stylesheets
│ └── styles.css
├── script.star # Starlark entry point
├── star/ # additional Starlark modules
├── assets/ # static assets (images, etc.)
└── migrations/ # schema migration files
Only manifest.json is required. All other directories are optional and included only if present in the source.
manifest.json Reference
{
"id": "my-package",
"title": "My Package",
"type": "surface",
"tier": "browser",
"version": "1.0.0",
"description": "What this package does.",
"icon": "📦",
"author": "your-name",
"route": "/s/my-package",
"auth": "authenticated",
"permissions": [],
"api_routes": [],
"db_tables": {},
"settings": {},
"hooks": {},
"exports": [],
"schema_version": 1
}
Required Fields
| Field | Description |
|---|---|
id |
Unique kebab-case identifier |
title |
Human-readable display name |
type |
surface, extension, full, library, workflow |
tier |
browser, starlark, sidecar |
version |
Semver version string |
Optional Fields
| Field | Description |
|---|---|
description |
Short description |
icon |
Emoji for sidebar/menu display |
author |
Package author |
surfaces |
Array of surface entries with per-path access, title, layout (see below) |
route |
(deprecated — use surfaces) URL path for surfaces |
auth |
Default access level for all surfaces: authenticated, public, admin |
layout |
Default layout for all surfaces: single, editor |
permissions |
Array of required capabilities (db.write, http, notifications, secrets, realtime.publish) |
api_routes |
Array of {"method": "GET", "path": "/items"} |
db_tables |
Table definitions with columns and indexes |
settings |
User-configurable settings with type, label, description, default |
hooks |
Event bus subscription patterns |
exports |
Functions exported for cross-package calls via lib.require() |
depends |
Array of package IDs this package depends on |
slots |
Named UI injection points (host surfaces declare these) |
contributes |
Slot contributions this package injects into other surfaces |
capabilities |
Environment requirements: {"required": [...], "optional": [...]} |
schema_version |
Integer for additive schema migrations |
form_template |
Typed form template (v0.9.5). Fields array or fieldsets for progressive forms. Validated at install. |
Multi-Surface Packages
A package can serve multiple pages, each with its own path, access level,
title, and layout. Declare a surfaces array in the manifest:
{
"id": "bug-tracker",
"title": "Bug Tracker",
"type": "full",
"auth": "authenticated",
"layout": "single",
"surfaces": [
{ "path": "/", "title": "Dashboard" },
{ "path": "/submit", "access": "public", "title": "Report a Bug" },
{ "path": "/:id", "title": "Bug Detail", "nav": false },
{ "path": "/admin", "access": "admin", "title": "Settings", "nav": false }
]
}
Surface Entry Fields
| Field | Type | Default | Description |
|---|---|---|---|
path |
string | required | Relative to /s/{pkg-id}. Supports :param segments. |
access |
string | package auth |
public, authenticated, admin, group:{name}. |
title |
string | package title |
Human label. Used in nav if nav: true. |
layout |
string | package layout |
single, editor, or future layouts. |
nav |
bool | see rules | Show in sidebar navigation. |
Nav Visibility Rules
path: "/"defaults tonav: true(primary entry point)- All others default to
nav: false(sub-pages) - Explicit
"nav": trueoverrides — a package can put multiple entries in nav - The sidebar links to the first surface with
nav: true
Backward Compatibility
If surfaces is absent, the kernel synthesizes one entry from the legacy
auth and layout fields. No existing packages break.
Client-Side Navigation
Within a multi-surface package, use sw.navigate() for SPA-style routing:
const path = window.__SURFACE_PATH__ || '/';
const params = window.__SURFACE_PARAMS__ || {};
if (path === '/') renderDashboard();
if (path === '/submit') renderSubmitForm();
if (path === '/:id') renderDetail(params.id);
// Navigate to another surface within the package
sw.navigate('/submit');
sw.navigate('/:id', { id: 'bug-42' });
// Listen for navigation events (including back/forward)
sw.on('surface.navigate', ({ path, params }) => {
renderView(path, params);
});
Composability: Slots and Contributions
Packages compose with each other through named UI injection points (slots) and contributions.
Declaring Slots (Host Surfaces)
Surfaces declare slots where other extensions can inject UI:
{
"slots": {
"toolbar-actions": {
"description": "Toolbar action buttons",
"context": {
"noteId": "string — current note ID",
"getContent": "function — returns note body text"
}
}
}
}
Slot names are namespaced at runtime as {package-id}:{slot-name} (e.g., notes:toolbar-actions). The context field documents what data the slot provides — it is not enforced at runtime.
Contributing to Slots
Extensions declare which slots they inject into:
{
"contributes": {
"notes:toolbar-actions": {
"label": "Dictate",
"icon": "🎤",
"description": "Voice-to-text dictation"
}
}
}
Contributing extensions can be installed before or after their host surface — the coupling is soft. The admin can view all slots and their contributors at GET /api/v1/admin/slots.
Cross-Package Function Calls
Any package that declares exports can be called via lib.require(), not just library-type packages. The caller must declare the target in its depends array:
{
"depends": ["image-gen"],
"permissions": ["api.http"]
}
Package Lifecycle
-
Install: Upload a
.pkgfile via Admin > Packages orPOST /api/v1/admin/packages/install. The kernel extracts the archive, creates database tables, and registers routes. -
Enable: Activate the package so its routes, hooks, and UI become available.
PUT /api/v1/admin/packages/:id/enable. -
Disable: Deactivate without removing data.
PUT /api/v1/admin/packages/:id/disable. -
Update: Upload a new
.pkgwith a higher semver version. Schema changes must be additive (new columns/tables only).POST /api/v1/admin/packages/:id/update. -
Export: Download the installed package as a
.pkgarchive.GET /api/v1/admin/packages/:id/export. -
Delete: Remove the package and its data.
DELETE /api/v1/admin/packages/:id.
Bundled vs User-Installed
Bundled packages ship inside the Docker image at /app/bundled-packages. On first boot, a curated default set is auto-installed. Behavior:
- First boot: curated defaults are installed and enabled.
- Subsequent boots: already-installed packages are skipped.
- Admin uninstalls: the package stays uninstalled (never force-reinstalled).
- Control via
BUNDLED_PACKAGESenv var: empty = defaults,"*"= all, or comma-separated IDs.
User-installed packages are uploaded through the Admin UI or API. They follow the same lifecycle but are not tied to the Docker image.
Building Packages
Packages are built by zipping the standard directories alongside manifest.json:
# Build all packages in the packages/ directory
cd packages && bash build.sh
# Build a single package
cd packages && bash build.sh my-package
Output goes to dist/my-package.pkg.
Manual Build
cd packages/my-package
zip -r ../../dist/my-package.pkg manifest.json js/ css/ script.star
The build script automatically includes whichever standard directories exist: js/, css/, assets/, script.star, star/, migrations/.
Custom Docker Image
To bundle custom packages into a Docker image:
- Place your package in
packages/your-package/with amanifest.json. - Run
cd packages && bash build.sh all. - Build the image:
docker build -t my-armature .
The Dockerfile builds all packages and copies them into the bundled packages directory.