Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
18 KiB
Extensions
Plugin system with three tiers: Browser JS (client-side), Starlark sandbox (server-side, v0.29.0), Sidecar containers (server-side, future).
User Extensions
Auth: Authenticated user
GET /extensions → {"data": [...UserExtension]}
?tier=browser (optional filter by tier)
POST /extensions/:id/settings ← {"is_enabled": bool, "settings": {...}}
:id = extension UUID → {"ok": true}
GET /extensions/:id/manifest → {"data": <manifest JSON>}
:id = ext_id (manifest id)
GET /extensions/tools → {"data": [...tool schema objects]}
Notes:
GET /extensionsreturnsUserExtensionobjects: the base extension fields plususer_enabledanduser_settingsoverrides.- System extensions (
is_system: true) cannot be disabled by users. Attempting to setis_enabled: falseon a system extension returns 403. GET /extensions/toolsreturns raw tool schema JSON from all enabled browser extensions'manifest.tools[]arrays.
Extension API Routes (v0.29.1)
Starlark packages serve custom JSON endpoints. Mounted at
/s/:slug/api/*path with JWT authentication.
Auth: Authenticated user (JWT — returns 401, not redirect)
ANY /s/:slug/api/*path → Starlark on_request(req) response
Route: slug is the package ID. path is matched against the
manifest's api_routes declaration.
Manifest api_routes field:
{
"api_routes": [
{"method": "GET", "path": "/status"},
{"method": "POST", "path": "/webhook"},
{"method": "*", "path": "/proxy/*"}
]
}
method: exact match (case-insensitive), or"*"for any methodpath: exact match, or trailing"*"for prefix match- Boolean
trueshorthand: all routes forwarded - Missing or
false: no routes served
Request dict passed to on_request(req):
{
"method": "POST",
"path": "/webhook",
"headers": {"content-type": "application/json", ...},
"query": {"page": "1", ...},
"body": "{...}",
"user_id": "uuid"
}
Expected return dict:
{"status": 200, "headers": {"X-Custom": "val"}, "body": "{...}"}
status: int (default 200). ReturnNonefor 204 No Content.headers: dict (optional). Content-Type auto-detected if not set.body: string (default "").
Validation pipeline:
- Package exists and is enabled
- Status is
active(notpending_revieworsuspended) - Tier is
starlark api.httppermission is granted- Method+path matches
api_routesin manifest
Errors:
401— no/invalid JWT403— package suspended or missingapi.httppermission404— package not found, disabled, or route not declared400— package is not starlark tier500— Starlark execution error
Admin Extension Management
Auth: Admin role
GET /admin/extensions → {"data": [...Extension]}
POST /admin/extensions ← {ext_id*, name*, version?, tier?,
description?, author?, manifest?,
is_system?, is_enabled?}
→ {"data": Extension} (201)
PUT /admin/extensions/:id ← {name?, version?, description?,
:id = extension UUID author?, is_system?, is_enabled?,
manifest?}
→ {"data": Extension}
DELETE /admin/extensions/:id → {"ok": true}
:id = extension UUID
Install defaults: version → "0.0.0", tier → "browser",
manifest → {}, scope → "global".
Tier validation: tier must be one of: browser, starlark, sidecar.
Duplicate rejection: If ext_id is already installed, returns 409.
Asset Serving
Auth: None (public — script tags can't send Authorization headers)
GET /extensions/:id/assets/*path → application/javascript
:id = ext_id (manifest id)
Returns the inline _script field from the extension's manifest.
The *path segment is accepted but currently ignored (all requests
return the same script). Disabled extensions return 404.
Extension Permissions (v0.29.0+)
Starlark packages declare capabilities in manifest.permissions.
Admin must grant each before the package activates.
| Permission | Module | Description |
|---|---|---|
secrets.read |
secrets |
Read extension secrets via GlobalConfig |
notifications.send |
notifications |
Send in-app notifications |
filters.pre_completion |
— | Register pre-completion filter |
api.http |
http |
Outbound HTTP requests (v0.29.0: module, v0.29.1: also required for API routes) |
provider.complete |
provider |
LLM completion calls via BYOK chain (v0.29.1) |
db.read |
db |
Read extension tables and platform views (v0.29.2) |
db.write |
db |
Write extension tables (v0.29.2) |
Starlark Modules (v0.29.0+)
Modules injected into the script namespace based on granted permissions:
secrets (requires secrets.read):
secrets.get(key)→ string or Nonesecrets.list()→ list of key names
notifications (requires notifications.send):
notifications.send(user_id, title, body?, type?)→ True
http (requires api.http, v0.29.1):
http.get(url, headers?)→{"status": int, "headers": dict, "body": string}http.post(url, body?, headers?)→ response dicthttp.put(url, body?, headers?)→ response dicthttp.delete(url, headers?)→ response dicthttp.request(method, url, body?, headers?)→ response dict- SSRF protection: private/loopback/link-local IPs blocked after DNS
- Manifest
network_access:{"allow": [...]}or{"block": [...]}
provider (requires provider.complete, v0.29.1):
provider.complete(messages, model?, max_tokens?, temperature?)→ response dict- Response:
{"content", "model", "finish_reason", "input_tokens", "output_tokens"} - Provider resolved via BYOK chain; pinnable via
requires_provider.provider_config_id
db (requires db.read or db.write, v0.29.2):
db.query(table, filters=None, order=None, limit=100)→ list of dictstable: logical name (physical:ext_{pkg_slug}_{table})filters: dict of{column: value}equality filters (optional)order:"col"or"-col"for descending (optional)limit: max rows (default 100)
db.insert(table, row_dict)→ inserted row dict with auto-generatedid(db.write)db.update(table, id, partial_dict)→ True (db.write)db.delete(table, id)→ True (db.write)db.list_tables()→ list of logical table names for this packagedb.view(view_name, filters=None, limit=100)→ list of dicts- Allowed view names:
"users"→ext_view_users,"channels"→ext_view_channels ext_view_users:id,display_name,emailext_view_channels:id,title,type,team_id
- Allowed view names:
Extension Database Tables (v0.29.2)
Starlark packages declare owned tables in manifest.db_tables. Tables
are created on install and dropped on uninstall.
Manifest db_tables field:
{
"db_tables": {
"logs": {
"columns": {
"message": "text",
"user_id": "text",
"count": "int",
"score": "real",
"active": "bool",
"created_at": "timestamp"
},
"indexes": [
["user_id"],
["user_id", "created_at"]
]
}
}
}
- Physical name:
ext_{pkg_slug}_{logical_name}(hyphens → underscores) - Auto-columns:
id TEXT PRIMARY KEY(UUID generated on insert),created_at(dialect-correct timestamp default) - Column types:
text,int/integer,real/float,bool/boolean,timestamp— mapped to dialect-correct SQL - Indexes: each entry is a list of columns for a composite index
- Dialect: PG uses
TIMESTAMPTZ/BOOLEAN; SQLite usesTEXT/INTEGER - Catalog: tracked in
ext_data_tablesper package for lifecycle management
Extension Tools (v0.29.2)
Starlark packages declare server-side tools in manifest.tools. These
are included in BuildToolDefs alongside server tools. The completion
tool loop dispatches matched calls to the on_tool_call entry point.
Manifest tools field (starlark tier only):
{
"tier": "starlark",
"permissions": ["db.read"],
"tools": [
{
"name": "search_logs",
"description": "Search extension log entries",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"user_id": {"type": "string"}
},
"required": ["query"]
}
}
]
}
on_tool_call(call) entry point:
Called by the completion tool loop when a tool declared in tools is
invoked by the LLM.
def on_tool_call(call):
# call dict:
# {
# "tool_name": "search_logs",
# "tool_call_id": "call_abc123",
# "arguments": {"query": "hello", "user_id": "u1"}
# }
if call["tool_name"] == "search_logs":
rows = db.query("logs", filters={"user_id": call["arguments"]["user_id"]})
return {"results": rows}
return {"error": "unknown tool"}
- Return value is serialized to JSON and returned as the tool result
- All sandbox modules (including
db) are available per granted permissions - No additional permission is required beyond package being
active
Multi-File Starlark Packages (v0.38.0)
Starlark packages support load() for splitting code across multiple
files. The entry point script is script.star (or the entry_point
manifest field). Submodules live in star/.
Archive structure:
my-extension/
├── manifest.json ← metadata only (no _starlark_script)
├── script.star ← entry point (required for starlark tier)
├── star/ ← optional submodules
│ ├── auth.star
│ ├── repos.star
│ └── helpers.star
├── js/ ← optional surface assets
└── css/
Manifest entry_point field (optional):
{
"tier": "starlark",
"entry_point": "script.star"
}
Default is script.star. The installer validates the entry point exists
in the archive for starlark-tier packages (returns 400 if missing).
load() in Starlark scripts:
# script.star
load("star/auth.star", "auth_headers")
load("star/repos.star", "get_repos")
def on_request(req):
headers = auth_headers(conn)
repos = get_repos(conn)
return {"status": 200, "body": json.encode(repos)}
Constraints:
- Package-scoped: can only load files within the package's own directory
.starfiles only — cannot load.js,.json, etc.- Path traversal (
..) and absolute paths are rejected - Circular dependencies are detected and rejected
- Loaded files get the same injected modules (
db,http, etc.) as the entry point - All loaded files share the same step limit budget (1M ops total)
Backward compatibility: Existing packages with _starlark_script
in their manifest continue to work. The runner tries disk first, then
falls back to the manifest field. Reinstalling extracts script.star
to disk.
Install errors:
400—starlark package missing entry point "script.star"(or customentry_pointvalue)
5. Extension Connections (v0.38.1)
Scoped credential management for extensions that integrate with external services. Same scope/resolution pattern as provider configs (personal → team → global).
Personal Connections
Auth: Authenticated user
GET /connections → {"data": [...connection summaries]}
POST /connections ← {"type","package_id","name","config"} → {"id","type","name"}
GET /connections/:id → connection summary (owned only)
PUT /connections/:id ← {"name?","config?"} → {"message":"connection updated"}
DELETE /connections/:id → {"message":"connection deleted"}
GET /connections/resolve → resolved connection (decrypted config)
?type=gitea&name=Work+Gitea
Team Connections
Auth: Team admin (RequireTeamAdmin middleware)
GET /teams/:teamId/connections → {"data": [...connection summaries]}
POST /teams/:teamId/connections ← {"type","package_id","name","config"} → {"id","type","name"}
PUT /teams/:teamId/connections/:id ← {"name?","config?"} → {"message":"connection updated"}
DELETE /teams/:teamId/connections/:id → {"message":"connection deleted"}
Admin (Global) Connections
Auth: Admin
GET /admin/connections → {"data": [...connection summaries]}
POST /admin/connections ← {"type","package_id","name","config"} → {"id","type","name"}
PUT /admin/connections/:id ← {"name?","config?"} → {"message":"connection updated"}
DELETE /admin/connections/:id → {"message":"connection deleted"}
Connection summary (list/get responses — secrets masked):
{
"id": "uuid", "type": "gitea", "package_id": "git-board",
"scope": "personal", "name": "Work Gitea",
"is_active": true, "has_config": true,
"created_at": "2026-03-25T...", "updated_at": "2026-03-25T..."
}
Scope resolution: GET /connections/resolve?type=gitea resolves
via personal → team → global chain. Returns full connection with
decrypted config. Optional name parameter for named resolution.
Config encryption: Fields with type: "secret" in the package
manifest's connections[].fields are encrypted at rest using the
same vault pattern as provider API keys. The handler layer encrypts
on write and decrypts on read. The store is encryption-agnostic.
Unique constraint: (type, scope, owner_id, name) — a user cannot
have two connections of the same type with the same name.
Starlark module: Extensions with connections.read permission
get a connections module:
conn = connections.get("gitea") # scope chain
conn = connections.get("gitea", name="Work Gitea") # named
all = connections.list("gitea") # all accessible
Each returned dict contains id, type, name, scope plus all
config fields with secrets decrypted.
Library Packages (v0.38.2)
Library packages (type: "library") export Starlark functions for other
packages to consume. Libraries run with their own permission context,
isolating consumers from implementation details.
Manifest fields:
type: "library"— requiredtier: "starlark"— required (libraries are always starlark-tier)exports: ["fn_a", "fn_b"]— required, names of globals to exposepermissions: [...]— the library's own permissions (not inherited by consumers)dependencies: {"other-lib": ">=1.0.0"}— optional, libraries can depend on other libraries
Restrictions: Libraries cannot have tools, pipes, or a route.
Consumer usage (Starlark):
Consumers declare dependencies in their manifest:
{ "dependencies": { "gitea-client": ">=1.0.0" } }
Then load at runtime:
gitea = lib.load("gitea-client")
repos = gitea.get_repos(conn)
lib.load() validates the dependency record, loads the library's script
with the library's own permissions, extracts the declared exports, and
returns them as a frozen struct. Results are cached per invocation.
Admin endpoints:
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/admin/packages/:id/dependencies |
Libraries this package depends on |
| GET | /api/v1/admin/packages/:id/consumers |
Packages that depend on this library |
| GET | /api/v1/admin/dependencies |
Full dependency graph |
Uninstall protection: Libraries with active consumers return 409 on delete.
Config Sections (v0.38.3)
Packages can declare a config_section in their manifest to inject a
Preact configuration component into the Settings, Admin, or Team Admin
surfaces. This enables headless extensions and libraries to own their
configuration UX without requiring a full surface.
Manifest schema:
{
"config_section": {
"label": "My Extension",
"icon": "M12 2L2 7...",
"component": "js/config.js",
"surfaces": ["admin", "settings", "team-admin"],
"category": "system"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
label |
string | Yes | Nav link text. Falls back to package title if empty. |
icon |
string | No | SVG path data in compact format (admin CatIcon). |
component |
string | No | Relative path within the package archive. Default: js/config.js. |
surfaces |
string[] | Yes | Target surfaces: admin, settings, team-admin. |
category |
string | No | Admin-only: category tab to appear under. Default: system. |
Component contract:
The config section component is a standard ES module exporting a
default Preact component. It is lazy-loaded via dynamic import() from
the existing asset-serving endpoint (GET /surfaces/:id/:component).
The component uses sw.sdk to read/write its own package settings:
sw.api.admin.packages.settings(packageId)— readsw.api.admin.packages.updateSettings(packageId, data)— write
Settings are stored in the package_settings JSONB column (admin scope)
and package_user_settings table (user scope).
Discovery: At page load, the backend queries enabled packages for
config_section entries targeting the current surface and passes them
as __CONFIG_SECTIONS__ to the frontend. The surface merges them into
its nav and section module map.
No new tables or endpoints. Config sections are purely manifest-driven, using existing package storage and settings infrastructure.
Builtin Seeding
On startup, SeedBuiltinExtensions() scans extensions/builtin/
for subdirectories containing manifest.json + script.js. Each
is upserted as a system extension:
- New ext_id →
Createwithis_system: true - Same version → skip (idempotent)
- Different version →
Updatemanifest, name, description, author