All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 52s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
405 lines
12 KiB
Markdown
405 lines
12 KiB
Markdown
# Extension Guide
|
||
|
||
## Package Types
|
||
|
||
| Type | Description |
|
||
|------|-------------|
|
||
| `surface` | A routable UI page rendered in the shell viewport |
|
||
| `extension` | Starlark hooks, tools, API routes, DB tables |
|
||
| `full` | Both surface and extension combined |
|
||
| `library` | Shared code imported by other packages via `lib.require()` |
|
||
| `workflow` | Bundled workflow definition |
|
||
|
||
## Tiers
|
||
|
||
| Tier | Runs | Capabilities |
|
||
|------|------|-------------|
|
||
| `browser` | Client-side JS only | DOM access, SDK hooks, no server-side logic |
|
||
| `starlark` | Sandboxed server-side | DB, HTTP, notifications, secrets, API routes, realtime |
|
||
| `sidecar` | Separate container | Full runtime (future) |
|
||
|
||
## manifest.json
|
||
|
||
Every package has a `manifest.json` at its root. Example for a surface:
|
||
|
||
```json
|
||
{
|
||
"id": "my-surface",
|
||
"title": "My Surface",
|
||
"type": "full",
|
||
"tier": "starlark",
|
||
"version": "1.0.0",
|
||
"description": "A custom surface with server-side logic.",
|
||
"icon": "🔧",
|
||
"author": "you",
|
||
"route": "/s/my-surface",
|
||
"auth": "authenticated",
|
||
"permissions": ["db.write"],
|
||
"api_routes": [
|
||
{"method": "GET", "path": "/items"},
|
||
{"method": "POST", "path": "/items"}
|
||
],
|
||
"db_tables": {
|
||
"items": {
|
||
"columns": {
|
||
"title": "text",
|
||
"done": "int"
|
||
},
|
||
"indexes": [["title"]]
|
||
}
|
||
},
|
||
"settings": {
|
||
"page_size": {
|
||
"type": "string",
|
||
"label": "Items Per Page",
|
||
"description": "Number of items shown per page",
|
||
"default": "25"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Field Reference
|
||
|
||
| Field | Required | Description |
|
||
|-------|----------|-------------|
|
||
| `id` | yes | Unique kebab-case identifier |
|
||
| `title` | yes | Display name |
|
||
| `type` | yes | `surface`, `extension`, `full`, `library`, `workflow` |
|
||
| `tier` | yes | `browser`, `starlark`, `sidecar` |
|
||
| `version` | yes | Semver string |
|
||
| `description` | no | Short description |
|
||
| `icon` | no | Emoji icon for sidebar/menu |
|
||
| `route` | surfaces | URL path (e.g., `/s/my-surface`) |
|
||
| `auth` | no | `authenticated` (default) or `public` |
|
||
| `permissions` | no | Sandbox capabilities: `db.write`, `db.read`, `api.http`, `notifications`, `secrets`, `realtime.publish`, `connections.read`, `workflow.access`, `batch.exec`, `files.read`, `files.write`, `workspace.manage` |
|
||
| `api_routes` | no | Array of `{method, path}` for extension HTTP endpoints |
|
||
| `api_schema` | no | OpenAPI documentation for extension API routes (see below) |
|
||
| `db_tables` | no | Table definitions (see below) |
|
||
| `settings` | no | User-configurable settings schema |
|
||
| `exports` | no | Functions exported for cross-package calls via `lib.require()` |
|
||
| `depends` | no | Array of package IDs this package depends on |
|
||
| `slots` | no | Named UI injection points for host surfaces |
|
||
| `contributes` | no | Slot contributions into other surfaces |
|
||
| `hooks` | no | Event bus subscriptions |
|
||
| `config_section` | no | Settings/Admin panel injection (see below) |
|
||
| `capabilities` | no | Environment requirements (see below) |
|
||
| `user_permissions` | no | Permissions this extension registers for users (see below) |
|
||
| `gate_permission` | no | Permission checked before `on_request` executes |
|
||
| `schema_version` | no | Integer for additive schema migrations |
|
||
|
||
## db_tables Schema
|
||
|
||
Tables are automatically namespaced as `ext_{package_id}_{table_name}`.
|
||
Every table gets an auto-generated `id` primary key and `created_at` timestamp.
|
||
|
||
Column types: `text`, `int`, `vector(N)`.
|
||
|
||
The `vector(N)` type stores N-dimensional float vectors for similarity
|
||
search via `db.query_similar()`. Storage adapts to the backend:
|
||
Postgres + pgvector uses native `vector(N)` with HNSW indexes,
|
||
Postgres without pgvector uses `JSONB`, SQLite uses `TEXT`. N can be
|
||
1–4096. See the [Starlark Reference](STARLARK-REFERENCE) for query API.
|
||
|
||
```json
|
||
"db_tables": {
|
||
"notes": {
|
||
"columns": {
|
||
"title": "text",
|
||
"body": "text",
|
||
"creator_id": "text",
|
||
"pinned": "int"
|
||
},
|
||
"indexes": [
|
||
["creator_id"],
|
||
["pinned"]
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
## api_schema (OpenAPI Documentation)
|
||
|
||
Extensions can optionally declare an `api_schema` array in their manifest to provide rich API documentation. Declared routes appear in Swagger UI at `/api/docs` with parameters, request bodies, and response schemas. Routes without `api_schema` entries still appear as auto-generated stubs.
|
||
|
||
```json
|
||
"api_schema": [
|
||
{
|
||
"path": "/items",
|
||
"method": "GET",
|
||
"summary": "List items",
|
||
"description": "Returns paginated items for the current user",
|
||
"params": {
|
||
"limit": {"type": "integer", "default": 50, "description": "Max results"},
|
||
"offset": {"type": "integer", "default": 0}
|
||
},
|
||
"response": {
|
||
"type": "object",
|
||
"example": {"data": [{"id": "string", "title": "string"}]}
|
||
}
|
||
},
|
||
{
|
||
"path": "/items",
|
||
"method": "POST",
|
||
"summary": "Create item",
|
||
"body": {
|
||
"title": {"type": "string", "required": true},
|
||
"description": {"type": "string"}
|
||
}
|
||
}
|
||
]
|
||
```
|
||
|
||
Only `path` and `method` are required. All other fields are optional. Malformed entries are logged and skipped without blocking extension loading.
|
||
|
||
## Capabilities
|
||
|
||
Extensions can declare environment requirements via the `capabilities`
|
||
manifest field. The kernel validates these at install time.
|
||
|
||
```json
|
||
{
|
||
"capabilities": {
|
||
"required": ["postgres"],
|
||
"optional": ["pgvector", "workspace"]
|
||
}
|
||
}
|
||
```
|
||
|
||
- **required** — install is rejected (HTTP 422) if any capability is missing.
|
||
- **optional** — install succeeds with a logged warning. Query at runtime
|
||
with `settings.has_capability("pgvector")` to adapt behavior.
|
||
|
||
Detected capabilities: `pgvector`, `workspace`, `object_storage`, `s3`,
|
||
`postgres`. The admin can view detected capabilities at
|
||
**Admin > System > Capabilities** or via `GET /admin/capabilities`.
|
||
|
||
## User Permissions
|
||
|
||
Extensions can register custom permissions that the admin assigns to
|
||
user groups. This controls access to extension features beyond the
|
||
sandbox permission model.
|
||
|
||
```json
|
||
{
|
||
"user_permissions": ["image-gen.use", "image-gen.admin"],
|
||
"gate_permission": "image-gen.use"
|
||
}
|
||
```
|
||
|
||
- **user_permissions** — on install, these are merged into the kernel's
|
||
permission registry. On uninstall, they are removed. The admin assigns
|
||
them to groups in **Admin > Groups**.
|
||
- **gate_permission** — if set, the kernel checks this permission before
|
||
calling `on_request`. Unauthorized users get a 403 without the
|
||
extension code executing.
|
||
|
||
In Starlark, check permissions inline via `req["permissions"]` or call
|
||
`permissions.check(user_id, "image-gen.use")`.
|
||
|
||
## Extension Composability
|
||
|
||
Extensions compose with each other through three mechanisms: manifest-declared
|
||
**slots** (UI injection points), **contributions** (UI components injected into
|
||
those slots), and cross-package **function calls** via `lib.require()`.
|
||
|
||
### Slots — Host Surfaces Declare Injection Points
|
||
|
||
A surface declares named slots in its manifest where other extensions can
|
||
inject UI components:
|
||
|
||
```json
|
||
{
|
||
"id": "notes",
|
||
"type": "surface",
|
||
"slots": {
|
||
"toolbar-actions": {
|
||
"description": "Toolbar action buttons",
|
||
"context": {
|
||
"noteId": "string",
|
||
"getContent": "function — returns note body",
|
||
"setContent": "function — replaces note body"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
The surface renders slot contents using the SDK helper:
|
||
|
||
```javascript
|
||
html`<div class="toolbar">
|
||
${sw.slots.renderAll('notes:toolbar-actions', {
|
||
noteId: note.id,
|
||
getContent: () => editor.getValue(),
|
||
setContent: (text) => editor.setValue(text),
|
||
})}
|
||
</div>`
|
||
```
|
||
|
||
### Contributions — Extensions Inject UI
|
||
|
||
An extension declares which slots it contributes to:
|
||
|
||
```json
|
||
{
|
||
"id": "note-dictate",
|
||
"type": "extension",
|
||
"contributes": {
|
||
"notes:toolbar-actions": {
|
||
"label": "Dictate",
|
||
"icon": "🎤",
|
||
"description": "Voice-to-text dictation"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
In its JavaScript, it registers the component:
|
||
|
||
```javascript
|
||
sw.slots.register('notes:toolbar-actions', {
|
||
id: 'note-dictate',
|
||
priority: 200,
|
||
component: ({ noteId, setContent, getContent }) => {
|
||
// ... component implementation
|
||
return html`<button class="sw-btn sw-btn--ghost">🎤</button>`;
|
||
}
|
||
});
|
||
```
|
||
|
||
Contributions are soft-coupled — install order doesn't matter. The admin
|
||
can view all slots and contributors at **Admin > Packages** or via
|
||
`GET /api/v1/admin/slots`.
|
||
|
||
### Cross-Package Function Calls
|
||
|
||
Any package that declares `exports` in its manifest can be called by other
|
||
packages via `lib.require()`. The caller declares the dependency:
|
||
|
||
```json
|
||
{
|
||
"id": "note-ai",
|
||
"depends": ["llm-bridge"],
|
||
"permissions": ["api.http"]
|
||
}
|
||
```
|
||
|
||
In Starlark:
|
||
|
||
```python
|
||
llm = lib.require("llm-bridge")
|
||
result = llm.complete([{"role": "user", "content": prompt}])
|
||
```
|
||
|
||
The called function runs with the *target* package's permissions, not the
|
||
caller's. This is the same security model as library packages.
|
||
|
||
### Slot Naming Convention
|
||
|
||
Slot names follow `{host-package-id}:{slot-name}`. The colon separates the
|
||
namespace from the slot. Standard slots for first-party packages:
|
||
|
||
| Slot | Host | Use Case |
|
||
|------|------|----------|
|
||
| `notes:toolbar-actions` | notes | Dictation, AI tools, formatting |
|
||
| `notes:note-footer` | notes | Related items, AI summary |
|
||
| `chat:composer-tools` | chat | Image gen, file attach |
|
||
| `chat:message-actions` | chat | Reactions, translate, bookmark |
|
||
| `chat:image-actions` | chat | Regen, edit, upscale |
|
||
|
||
## Starlark Sandbox API
|
||
|
||
Starlark scripts run server-side with a 1M operation budget and no
|
||
filesystem access. See the [Starlark Reference](STARLARK-REFERENCE) for
|
||
the complete module catalog, function signatures, and permission gates.
|
||
|
||
## config_section — Settings Panel Injection
|
||
|
||
Packages can inject configuration panels into the Settings, Admin, or
|
||
Team Admin surfaces. Declare `config_section` in `manifest.json`:
|
||
|
||
```json
|
||
{
|
||
"config_section": {
|
||
"label": "My Config",
|
||
"icon": "M12 2L2 7l10 5 10-5-10-5z",
|
||
"component": "js/config.js",
|
||
"surfaces": ["settings", "admin"],
|
||
"category": "system"
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Required | Description |
|
||
|-------|----------|-------------|
|
||
| `label` | yes | Navigation label shown in the sidebar or tab list |
|
||
| `icon` | no | SVG path data for the nav icon |
|
||
| `component` | no | JS asset path (default: `js/config.js`). Must `export default` a Preact component. |
|
||
| `surfaces` | yes | Target surfaces: `"settings"`, `"admin"`, `"team-admin"` |
|
||
| `category` | no | Admin surface category tab (default: `"system"`). Ignored for settings/team-admin. |
|
||
|
||
**How it works:**
|
||
|
||
1. At page load, the backend scans all enabled packages for `config_section`
|
||
entries targeting the current surface.
|
||
2. Matching sections are injected into the page as `__CONFIG_SECTIONS__`.
|
||
3. The frontend dynamically imports the component module and renders it
|
||
as an additional tab/section.
|
||
4. The component receives `{ packageId, teamId }` as props.
|
||
|
||
**Example component** (`js/config.js`):
|
||
|
||
```javascript
|
||
const { html } = window;
|
||
const { useState, useEffect } = hooks;
|
||
|
||
export default function MyConfig({ packageId }) {
|
||
const [val, setVal] = useState('');
|
||
|
||
useEffect(() => {
|
||
sw.api.ext(packageId).get('/settings').then(r => setVal(r.value));
|
||
}, []);
|
||
|
||
return html`<div>
|
||
<label>API Key</label>
|
||
<input value=${val} onInput=${e => setVal(e.target.value)} />
|
||
<button onClick=${() => sw.api.ext(packageId).put('/settings', { value: val })}>Save</button>
|
||
</div>`;
|
||
}
|
||
```
|
||
|
||
## Permissions Model
|
||
|
||
Extensions declare required permissions in `manifest.json`. The admin
|
||
must grant each permission before the extension can use the corresponding
|
||
sandbox module. Permission status is visible in **Admin > Packages > Permissions**.
|
||
|
||
See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for the full RBAC
|
||
model, user permission slugs, and settings cascade.
|
||
|
||
## File Structure
|
||
|
||
```
|
||
my-package/
|
||
manifest.json # required
|
||
js/ # browser-side JavaScript
|
||
index.js
|
||
css/ # stylesheets
|
||
styles.css
|
||
script.star # Starlark entry point
|
||
star/ # additional Starlark modules
|
||
assets/ # static assets
|
||
migrations/ # schema migration files
|
||
```
|
||
|
||
## Testing Extensions
|
||
|
||
1. Build the package: `cd packages && bash build.sh my-package`
|
||
2. Upload the `.pkg` file via Admin > Packages > Install.
|
||
3. Grant permissions in Admin > Packages > Permissions.
|
||
4. Enable the package.
|
||
5. If it is a surface, navigate to its route (e.g., `/s/my-package`).
|
||
|
||
Extension API routes are accessible at `/s/{slug}/api/{path}` and require an authenticated Bearer token.
|