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 claimweband shadowweb.search. - The namespace is checked against every other installed plugin’s namespace
(
PluginHost.install()inpackages/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:
- 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 bothinstallPluginFromDirectory()andPluginHost.install()). Refusing late — at call time — would mean the plugin was already running in the process; refusing at install means it never was. - The context a plugin skill receives has only what it was granted. No
memorygrant meansctx.memoryisundefinedin 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'| Kind | What it is | Where it runs |
|---|---|---|
skill | A typed function an agent can call as a tool — zod-in, zod-out | Server |
generator | A multi-step Studio pipeline, same shape as the built-in catalogue | Server (the host’s agent runtime executes the steps) |
widget | A tile on the Living Desktop | Browser |
integration | A connectable provider, listed in Settings → Connections | Both (OAuth2, API key, or webhook auth) |
viewer | Renders a Studio project output, read-only | Browser |
editor | Renders and saves back a Studio project output — everything a viewer does, plus write, always routed through the host’s file API | Browser |
theme | A whole world: background, palette, glass, motion, type — the plugin equivalent of a hand-saved Theme | Browser |
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.
| Kind | The seam | What is missing |
|---|---|---|
widget | WidgetType 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 widget | Loading and rendering the plugin’s component. In flight — see below |
viewer / editor | A 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 call | The path from an installed manifest entry to a loaded browser module. Nothing has registered one yet |
theme | Themes are already data you can install, sell and wear | ShaderPack is still a closed enum of seven, so a plugin can contribute a palette but not a sky |
integration | ConnectionProvider 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 from | Nothing 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 noallow-same-origin, so the frame has an opaque origin: no session cookie, no access to the app’s storage, no way to reach/apias 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", andsrcdocrather than asrcURL.
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-pinnedinstallPluginFromDirectory() 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 nowGET /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 statementPOST /api/plugins owner only. { dir, granted[] }. Refuses with 400 and the missing capability kinds named, before `main` is resolvedDELETE /api/plugins/:id owner only. Unregisters, deletes the row, removes the directoryUninstall 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,filesystemandprocessareguardrail/unenforced, neverbrokered, 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-actingis refused in the registry, before the plugin’s code is reached, unless a human approved that exact call.storageis 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,llmandcreditsareunenforced. 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 tobrokeredin the same commit.