extension.json Reference

Every field the extension manifest accepts.

The full manifest

The manifest is parsed into the ExtensionManifest struct in internal/cms/extension_loader.go. The kernel is forgiving with the file: missing optional fields are silently skipped, unknown fields are ignored, and extras don't cause the extension to fail to load. That tolerance is deliberate — it's also why mistakes are easy to miss. Run core.extension.standards after editing the manifest to audit it against the recommended shape and catch typos and orphaned references.

Required fields

  • name (string) — display name, e.g. "Forms".
  • slug (string) — kebab-case identifier; must equal the directory name under extensions/. The loader will fall back to the directory name if slug is omitted, but explicit is better than implicit.
  • version (string) — semver, e.g. "2.0.0". Used in plugin handshake metadata and surfaced in admin lists.

Everything else is optional. The minimal extension is three lines of JSON in a directory.

extensions/hello/extension.json
{
  "name": "Hello",
  "slug": "hello",
  "version": "0.1.0"
}

Identity and lifecycle

  • author (string) — displayed in the admin extensions list.
  • description (string) — free-form summary surfaced in the admin.
  • priority (int, default 50) — execution order across extensions. Lower runs first. Use it to enforce ordering between extensions that subscribe to the same event.
  • provides (string[]) — capabilities this extension advertises, e.g. ["media-provider"] or ["email.provider"]. The kernel uses these tags to resolve provider lookups: core.media.upload routes to whichever active extension declares media-provider; core.email.send routes to whichever declares email.provider. If two are active, the higher-priority extension wins.
  • auto_activate (bool) — when true, the row is created with is_active=true on the first install. The flag is ignored on existing installs (the is_active column is not in the upsert update set, so user choices stick). This replaces the old kernel-side hardcoded list of "core" extensions.

Capability and ownership fields

  • capabilities (string[]) — the CoreAPI permissions this extension may use. Calls outside the declared set return permission denied — there is no override flag.
  • data_owned_tables (string[]) — table names this extension owns. The data-store CoreAPI rejects reads/writes on tables that aren't owned (or are kernel-private). Migrations create these tables; the loader does not auto-derive ownership from migration content, so you must list every owned table explicitly.

gRPC plugin fields

plugins is an array of {binary, events} entries. Each entry launches one plugin process. Most extensions ship a single plugin (or none).

  • binary (string) — path to the executable, relative to the extension directory. Conventionally bin/<slug>.
  • events (string[]) — declarative subscriptions for this plugin. Equivalent to returning the same names from GetSubscriptions() at runtime. Wildcards (*, node.*) are supported.

Empty or missing plugins means the extension has no compiled binary — it's Tengo-only or UI-only.

Routing fields

public_routes is an array of {method, path}. Method is uppercased automatically. The wildcard * at the end of a path captures the rest. Reserved path roots are rejected with a warning at registration time — see the Extensions Overview for the full list. Admin proxy routes do not need declaration: anything under /admin/api/ext/<slug>/* is auto-proxied to the plugin's HandleHTTPRequest.

admin_routes is an array of {method, path, required_capability}. The proxy enforces the capability gate before forwarding to the plugin so a logged-in admin without the right cap cannot bypass UI guards by POSTing directly. Without this list, extensions are gated only by admin_access — fine for read-only extensions, a hole for anything mutating data the user shouldn't touch.

Admin UI fields

If your extension ships a micro-frontend, declare it under admin_ui. The loader does not validate the path — a typo silently breaks the UI — so verify the build output exists at the path you reference.

extension.json
"admin_ui": {
  "entry": "admin-ui/dist/index.js",
  "routes": [
    { "path": "/admin/forms",     "component": "FormsListPage" },
    { "path": "/admin/forms/:id", "component": "FormEditorPage" }
  ],
  "menu": {
    "label":    "Forms",
    "icon":     "FormInput",
    "section":  "Content",
    "position": "30",
    "path":     "/admin/forms"
  },
  "slots":       { "node-editor.sidebar.bottom": { "component": "FormPickerSlot", "label": "Linked forms" } },
  "field_types": [ { "type": "vibe-form", "label": "Form picker", "component": "VibeFormFieldInput", "icon": "FormInput", "group": "Relational" } ]
}
  • entry — relative path to the built ES module. Required if admin_ui is present.
  • routes — admin-side React routes. component must be a named export from entry. Routes use the same path-pattern grammar as React Router.
  • menu — top-level sidebar entry. section places it under one of the SPA shell's known sections (Content, Design, Development, Settings); position orders within the section. Use children for sub-items.
  • slots — named injection points the SPA shell exposes (e.g. node-editor.sidebar.bottom, node-editor.toolbar.left). Slot keys are hard-coded by the shell; an unknown key is silently ignored.
  • field_types — custom field-type registrations. Once registered, you can use type: "vibe-form" in any node-type or block field schema and the editor will mount the declared React component.

Settings fields

Two surfaces, used for different purposes:

  • settings_schema (map of name → SettingsField) — the lightweight per-extension settings the admin can edit from the extension's row. Each field is {type, label, required, default, sensitive, enum}. Sensitive fields are encrypted at rest using the AES-256 master key.
  • settings (raw JSON array) — the rich, schema-driven settings declaration. Each entry is a complete settings.Schema (sections + fields with translatable flags). Registered into the in-process settings registry on activation, unregistered on deactivation. The schema id is namespaced to ext.<slug>.<id> automatically so two extensions can declare schemas with the same local id without collision.

Use settings_schema for a handful of API keys; reach for settings when the extension wants its own settings page with sections, multilingual fields, and gated capabilities.

Theme-asset fields

An extension can ship blocks, layouts, partials, page templates, and assets, exactly like a theme. Image sizes are theme-only — they live on theme.json's image_sizes, not on the extension manifest.

  • blocks — content block definitions: {slug, label, field_schema, html_template, test_data, icon, block_css, block_js, cache_output}. Either inline the template or reference a file under blocks/<slug>/view.html.
  • layouts / partials — page-layout templates the extension contributes to the active theme.
  • templates — pre-built page block sequences for one-click insertion in the editor.
  • assets — array of {key, src, alt}. Bundled images imported into the media library on activation; subsequent template lookups use theme-asset:<key> to find them.
RULE OF THUMB
Extensions own data; themes own presentation. If you find yourself shipping migrations from a theme, you wanted an extension. If you find yourself shipping image-size declarations from an extension, you wanted a theme. The boundary is sharper than it looks at first.

Validation

The loader does not fail on a missing optional field — it just silently skips that surface. Unknown fields are ignored. Run core.extension.standards at any point to audit your manifest against the recommended shape, including: orphaned component references, entry paths that don't exist on disk, data_owned_tables entries that aren't actually created by any migration, and capabilities declared but never used. The result is a list of {severity, code, message} entries you can iterate before declaring an extension done.