Skip to content

Plugins and extensibility

Everything else in this documentation describes Novaterra as it ships. This section describes how someone else adds to it — a skill an agent can call, a generator in the Studio, a tile on the Living Desktop, a connectable provider, a way to render or edit a Studio output, or a whole visual theme — without touching this repository at all. @novaterra/plugin-sdk and packages/contracts/src/plugins.ts are what tonight built to make that possible; this is the platform story, and it deserves to be taken as seriously as any other pillar.

One package, one manifest

A plugin is a single npm package. It ships a manifest — novaterra.plugin.json at the package root, or the same object under the novaterra key of package.json — that the host reads and fully validates without importing a line of the plugin’s code. That is what lets an install screen list a plugin’s capabilities honestly before any of it has run.

PluginManifest = {
apiVersion: '1.0', // major.minor the plugin was built against
id: '@author/name', // npm-shaped
version: '1.0.0', // semver
namespace: 'acme', // one lowercase segment, see below
name, description, icon, author, license,
main: string | null, // server entry the host imports and calls activate() on
capabilities: PluginCapability[], // what it may do — the enforceable part
extensions: PluginExtension[], // what it contributes — see below
pricing: { model: 'free' | 'one-time' | 'per-run' | 'subscription', credits: number },
minHostVersion: string | null,
}

Namespacing makes collisions impossible, not merely checked

Every name a plugin contributes lives under its own namespace — one lowercase segment the plugin picks — and the full, addressable name is always <namespace>.<extensionId> (pluginExtensionName() in packages/contracts/src/plugins.ts). A plugin with namespace acme and an extension forecast is acme.forecast; another plugin can ship its own search extension as globex.search right alongside the built-in web.search, with zero risk of shadowing it.

This is enforced twice, at install time, before either name exists in the registry:

  • The namespace is checked against RESERVED_NAMESPACES — the built-in skill families (web, files, code, llm, email, http, …) and the host’s own words (system, core, admin, host, plugin). A plugin cannot claim web and shadow web.search.
  • The namespace is checked against every other installed plugin’s namespace (PluginHost.install() in packages/skills/src/plugins/host.ts) — two plugins cannot claim the same one, and the second install is refused outright, not merged or overwritten.

That’s what makes two plugins both shipping a search extension a non-event rather than a collision to detect and resolve: the namespace is the collision-avoidance mechanism, not a convention layered on top of one.

Capabilities: a closed, enforceable vocabulary

A plugin declares every power it wants from one fixed, eleven-member enum (CapabilityKind in packages/contracts/src/plugins.ts) — there is no way to ask for something outside it:

network · filesystem · process · llm · storage · memory · signals · schedule · integrations · credits · world-acting

Two rules make this more than paperwork:

  1. A capability declared but not granted refuses the install, not the call. The owner’s grant is checked against the manifest before a single line of the plugin’s module body has executed (ungrantedCapabilities(), checked in both installPluginFromDirectory() and PluginHost.install()). Refusing late — at call time — would mean the plugin was already running in the process; refusing at install means it never was.
  2. The context a plugin skill receives has only what it was granted. No memory grant means ctx.memory is undefined in the running code, not a stub that throws on use. There is no ambient path to a capability a plugin was not handed — no global, no singleton, nothing to reach for instead.

world-acting is the one capability that maps directly onto the approval gate the rest of Novaterra already runs on (see World-acting skills need a yes). Declare it on any extension that sends a message someone else reads, reaches an arbitrary endpoint, or executes code, and the registry refuses to run that call — plugin or built-in, no difference — unless a human has answered “Do it” on the exact decision Signal it raised. WorldActingCapability.reason is mandatory and shown to the owner verbatim in that signal. This is the same mechanism, not an analogous one: SkillRegistry.invoke() checks WORLD_ACTING_SKILLS for a built-in and a plugin skill’s own declared capabilities for everything else, and both paths converge on the identical refusal.

The seven contribution kinds

ExtensionKind = 'skill' | 'generator' | 'widget' | 'integration' | 'viewer' | 'editor' | 'theme'
KindWhat it isWhere it runs
skillA typed function an agent can call as a tool — zod-in, zod-outServer
generatorA multi-step Studio pipeline, same shape as the built-in catalogueServer (the host’s agent runtime executes the steps)
widgetA tile on the Living DesktopBrowser
integrationA connectable provider, listed in Settings → ConnectionsBoth (OAuth2, API key, or webhook auth)
viewerRenders a Studio project output, read-onlyBrowser
editorRenders and saves back a Studio project output — everything a viewer does, plus write, always routed through the host’s file APIBrowser
themeA whole world: background, palette, glass, motion, type — the plugin equivalent of a hand-saved ThemeBrowser

Every extension additionally declares its own uses: CapabilityKind[] — a subset of the plugin’s own capabilities — which is what lets one plugin ship a read-only widget with no network and no llm in its context, right alongside a skill from the same package that has both. defineWidget, defineIntegration, defineViewer, defineEditor, and defineTheme all exist in @novaterra/plugin-sdk for authoring these; only skill and generator have a worked example and a runtime today (see below).

How close each browser-side kind actually is

Three of the four browser-side kinds have had their host-side seam built since this page was written. None of them has been driven end to end by an actual plugin, and that distinction is the whole point of this section.

KindThe seamWhat is missing
widgetWidgetType is no longer a closed enum — it is an open, validated <namespace>.<id> string, so a plugin’s widget type can be saved to a layout, and an unknown-but-well-formed type renders a named “not installed” placeholder rather than silently substituting another widgetLoading and rendering the plugin’s component. In flight — see below
viewer / editorA real registry (apps/web/src/files/registry.ts): registerPluginViewer(id, manifestEntry, load) takes a ViewerExtension or EditorExtension from a manifest verbatim, and priority ordering means a plugin declaring priority > 0 wins an extension without a code change in the host. The ten built-in viewers are registered through the same callThe path from an installed manifest entry to a loaded browser module. Nothing has registered one yet
themeThemes are already data you can install, sell and wearShaderPack is still a closed enum of seven, so a plugin can contribute a palette but not a sky
integrationConnectionProvider is no longer a closed enum — it is an open, validated <namespace>.<provider> string, backed by a real provider registry at apps/api/src/twin/providers/, and GET /api/connections/providers lists every provider the registry holds, built-in or plugin-contributed, with the auth descriptor a connect flow renders fromNothing has registered a plugin-contributed provider through it yet. The registry exists and the enum is open; a plugin actually using it does not

Modules: a plugin that is a Place — shipped

A module is a plugin that contributes a whole Place: a rail item, a route, and its own interface. This is no longer a description of a plan — apps/web/src/modules/ is seven tracked files (api.ts, bridge.ts, ModuleFrame.tsx, modules.css, ModuleWidgetHost.tsx, Place.tsx, places.ts), the rail merges built-in Places with installed modules, and a module Place is mounted at /m/<namespace>.<route>. ListingKind carries module as one of its six members alongside generator, skill, agent, service and theme, so a module is something the Marketplace can list like anything else.

The interface is an HTML document the host fetches and hands to an iframe as srcdoc, rendered with:

  • sandbox="allow-scripts" and no allow-same-origin, so the frame has an opaque origin: no session cookie, no access to the app’s storage, no way to reach /api as you;
  • an injected default-src 'none'; connect-src 'none' content-security policy, so it cannot open a connection of its own at all;
  • referrerPolicy="no-referrer", and srcdoc rather than a src URL.

Everything a module does therefore arrives at POST /api/modules/:id/:extensionId/call, which refuses any capability outside uses ∩ grant, scoped to the session’s being throughout — the request body names no being, no plugin and no installation. There is a matching check on the client side, and it is defence in depth; the server one is the boundary.

The invariant this exists to protect is one sentence: a module never runs in the app’s origin. Loading modules as ES modules into the SPA would be easier and faster and would hand away everything.

This isn’t asserted, it’s demonstrated: tests/modules/frame-isolation.chrome.test.ts drives a real, installed Chrome over the DevTools Protocol — not jsdom, which implements no iframe sandbox at all and would pass this test for the wrong reason — and proves in that real browser that a module frame cannot read the session cookie, cannot reach /api (a CSP refusal: the request never leaves the page), and is refused an ungranted capability. It reads the sandbox attribute out of the shipping ModuleFrame.tsx component rather than retyping it, which is the load-bearing detail: the test exercises the app’s own code, not a copy of it. And it carries a negative control — the same suite, with allow-same-origin deliberately added to the sandbox attribute — that shows every one of those protections collapse at once, which is what proves the passing version is actually testing something rather than passing vacuously. CI runs this suite on every push (NOVA_CHROME_TESTS=1) and then asserts its exact test count, because a silently skipped security test looks exactly like a passing one. It skips on a plain local pnpm test and says so in the skipped test’s own title.

What “shipped” does not mean here: there is still no review queue a plugin or module passes through before it can be listed, and nobody outside this team has actually written and shipped one — every module and every plugin example in this repository was built by the people who built the host. The isolation boundary is real and proven; the ecosystem on top of it is still just this team.

Authoring a skill and a generator

Authoring a plugin walks the complete worked example (packages/plugin-sdk/examples/weather/plugin.ts, which typechecks as part of the SDK’s own build so it cannot go stale) end to end: a defineSkill that calls a real weather API, a defineGenerator that chains it into a written brief, and the definePlugin that ties them together with a manifest.

Be honest about isolation

A plugin’s module body — everything that runs before an agent ever calls one of its skills — is now genuinely isolated: packages/skills/src/plugins/loader.ts never imports a plugin’s code at all, only resolves its main to a path, and PluginHost refuses to register a skill from any non-builtin source without that path, so the actual import() of a plugin’s code happens inside a @novaterra/sandbox worker (or a container, for anything declaring the process capability), never in the API process. The capability grant, the namespace, the approval gate, and now the network/filesystem/storage capabilities themselves are enforced by a real boundary, rebuilt fresh on every single call so a revoked grant takes effect on the next invocation, not the next restart.

That is real progress, and it is not the same as “fully sandboxed.” Read Isolation and sandboxing for exactly what is covered — and, just as importantly, what still isn’t: the memory, signals, schedule, integrations and llm capabilities have no brokered equivalent yet, so a sandboxed plugin simply does not receive those adapters at all today, and a worker thread (the default tier for anything that doesn’t ask to execute code) is explicitly not a security boundary against a native addon or a V8 exploit.

Installing one

PluginSource =
| { kind: 'builtin' } // ships inside Novaterra
| { kind: 'local', dir: string } // <dataDir>/plugins/<id> — dev and self-hosting
| { kind: 'market', listingId, packageName, version } // installed through the Marketplace
| { kind: 'url', url, integrity: string | null } // a tarball, optionally hash-pinned

installPluginFromDirectory() is the whole local-directory path in one call: read and validate the manifest, refuse it if it asks for more than the owner granted, import() the code, register everything it declares. Install is all-or-nothing — if any one skill in the package fails to register (a namespace clash, an ungranted capability), every skill already registered from that same package is rolled back, so a half-loaded plugin can never exist.

The Marketplace path ({ kind: 'market' }) has the same trust properties as a local directory once the package is unpacked — fetching the tarball, verifying an integrity hash and publisher signature, and pinning the exact version bought are explicitly not done yet, and the code says so in a comment rather than pretending otherwise. restoreInstalledPlugins() marks any non-local source as an error at boot rather than guessing what it meant.

Four routes, and the split between them is the whole trust posture. Reading is open to anyone signed in — what is installed on this world is not a secret — and everything that changes what code runs is owner-only:

GET /api/plugins what is installed and what each one contributes right now
GET /api/plugins/available owner only. Every directory under <workspace>/plugins, read
WITHOUT importing any of their code, plus the whole grant
screen's copy: the capability notes and the risk statement
POST /api/plugins owner only. { dir, granted[] }. Refuses with 400 and the
missing capability kinds named, before `main` is resolved
DELETE /api/plugins/:id owner only. Unregisters, deletes the row, removes the directory

Uninstall deletes the grant with the installation, so a reinstall must ask again rather than reusing yesterday’s yes. It also removes the directory with a bounded retry — on Windows taskkill /T /F returns before the child actually exits, so a single delete races a process still holding the folder as its working directory — and when the folder survives every attempt the route says removedDir: false instead of reporting a clean uninstall.

What the grant screen has to say

The install screen is not free to be reassuring. packages/contracts/src/plugins.ts carries a CAPABILITY_ENFORCEMENT note per capability, classifying each as brokered, guardrail or unenforced, and an INSTALL_RISK_STATEMENT the screen may not omit: installing a plugin runs someone else’s code on this machine, and the sandbox raises the cost of misbehaving rather than making it impossible.

  • network, filesystem and process are guardrail/unenforced, never brokered, and a test pins that. A worker is not a security boundary; a grant screen that implied one would turn an informed risk into a false sense of safety.
  • Two capabilities are brokered, and only two. world-acting is refused in the registry, before the plugin’s code is reached, unless a human approved that exact call. storage is a quota-limited key/value area of the plugin’s own that no other plugin can read — one of the three the host actually wires a backend for.
  • memory, signals, schedule, integrations, llm and credits are unenforced. For the first five that is because granting them buys the plugin nothing: the host does not wire a backend for them into a sandboxed plugin today. When someone wires one, that row moves to brokered in the same commit.