Authoring a plugin
Everything on this page is packages/plugin-sdk/examples/weather/plugin.ts — a real file that
typechecks as part of @novaterra/plugin-sdk’s own build, so if the SDK’s shape ever changes,
this example breaks the build rather than quietly going stale in prose. npm install @novaterra/plugin-sdk is the only dependency; everything below imports from it.
1. A skill, with defineSkill
import { z, defineSkill } from '@novaterra/plugin-sdk';
const ForecastInput = z.object({ latitude: z.number().min(-90).max(90).describe('Decimal degrees, e.g. 47.6062'), longitude: z.number().min(-180).max(180).describe('Decimal degrees, e.g. -122.3321'), days: z.number().int().min(1).max(7).default(3).describe('How many days ahead to summarise'),});
const ForecastOutput = z.object({ place: z.string(), periods: z.array(z.object({ name: z.string(), temperature: z.number(), unit: z.string(), wind: z.string(), summary: z.string() })), source: z.string(),});
export const forecastSkill = defineSkill({ id: 'forecast', title: 'Weather forecast', description: 'The official forecast for a latitude/longitude, from the US National Weather Service.', icon: '🌤️', category: 'data', uses: ['network'], // per-extension, not per-plugin — see below input: ForecastInput, output: ForecastOutput, async run(input, ctx) { const point = await fetch(`https://api.weather.gov/points/${input.latitude},${input.longitude}`, { headers: { 'User-Agent': 'novaterra-weather-plugin' }, ...(ctx.signal ? { signal: ctx.signal } : {}), }); if (!point.ok) throw new Error(`api.weather.gov said ${point.status}. That service only covers the United States.`); // ... resolve the forecast URL from the point, fetch it, shape the periods ... return { place: 'Seattle, WA', periods: [], source: 'https://api.weather.gov' }; },});What each part is doing, and why it’s written this way:
inputandoutputare zod schemas, andrunis fully typed from them.inputarrives already parsed — no casts, nounknownnarrowing at the top of the function. The return value is validated againstoutputon the way back, so a malformed return is caught at the boundary instead of three steps later inside a generator that consumed it..describe()on a field is not decoration. It becomes the JSON Schema description a model reads when deciding how to call the skill. A field with a good description gets called correctly; a bare one gets guessed at.uses: ['network']is per-extension, not per-plugin. A plugin can ship one skill that reaches the internet and one widget that reaches nothing at all, and each gets a context sized to only what it declared.ctx.signalgoes into everyfetch. It aborts the moment the owner cancels the run — leave it out and the request keeps burning against the being’s budget after they’ve walked away.ctx.emit?.({ kind: 'progress', ... })puts a line in the Studio trace, free, with no capability required — the difference between a run that looks alive and one that looks hung.- Throw a sentence, not a code. The message reaches the being verbatim.
'api.weather.gov said 404. That service only covers the United States.'tells them what happened and why;'Request failed'does not.
2. A generator, with defineGenerator
A generator is data — a set of steps, each naming the skills it may call and a templated
instruction — that the host’s own agent runtime executes, resolving {{inputs.x}} and
{{steps.id.output}} along the way:
import { defineGenerator, form, str, num, text, pick, step } from '@novaterra/plugin-sdk';
export const morningBrief = defineGenerator({ id: 'morning_brief', title: 'Weather morning brief', slug: 'weather-morning-brief', description: 'A short daily brief: the forecast, what it means for your plans, and what to wear.', icon: '☕', uses: ['llm'], inputsSchema: form( { place: str('Place', { description: 'Where you are. A city is enough.', placeholder: 'Seattle, WA' }), latitude: num('Latitude', { minimum: -90, maximum: 90, default: 47.6062 }), longitude: num('Longitude', { minimum: -180, maximum: 180, default: -122.3321 }), plans: text('What you are doing today', { description: 'Optional. Tailors the advice.' }), tone: pick('Tone', ['Plain and quick', 'Warm', 'Dry and funny']), }, ['place', 'latitude', 'longitude'], // required fields 'Morning brief', ), outputKinds: ['markdown'], steps: [ step({ id: 'fetch', title: 'Get the forecast', role: 'researcher', skills: ['acme.forecast'], // the plugin's own skill, by its full <namespace>.<id> name outputKind: 'markdown', instruction: `Call acme.forecast with latitude {{inputs.latitude}}, longitude {{inputs.longitude}} and days 2. Return the result as a markdown table, one row per period.`, }), step({ id: 'write', title: 'Write the brief', role: 'writer', skills: ['llm.generate', 'docs.write_markdown'], dependsOn: ['fetch'], outputKind: 'markdown', instruction: `Write today's brief for {{inputs.place}}, tone {{inputs.tone}}. The forecast: {{steps.fetch.output}} Save it with docs.write_markdown as "morning-brief.md", then return the markdown.`, }), ],});The field helpers (str, text, num, bool, pick, form) produce JSON Schema with the hints
the Studio’s own form renderer reads — titles, help text, defaults, multiline, placement. step()
fills in the contract’s defaults so a step is a handful of lines, not fourteen. The host enforces
three rules on the step list that are worth checking before shipping:
- a step may only
dependsOnan earlier step; - every
outputKindthe generator declares must actually be produced by some step; {{steps.x.output}}in an instruction requiresdependsOn: ['x'].
3. The plugin, with definePlugin
import { definePlugin, cap } from '@novaterra/plugin-sdk';
export default definePlugin({ id: '@acme/weather', version: '1.0.0', namespace: 'acme', // acme.forecast, acme.morning_brief name: 'Weather', description: 'Official forecasts and a daily brief that reads like a person wrote it.', icon: '🌤️', author: { name: 'Acme', url: 'https://acme.example' }, license: 'MIT', main: './dist/plugin.js', capabilities: [ cap.network(['api.weather.gov'], 'Read the official forecast. This is the only host the plugin contacts.'), cap.llm('Write the morning brief.', ['cheap'], 4), ], extensions: [forecastSkill, morningBrief], pricing: { model: 'free' },});definePlugin validates everything at import time and throws if anything is inconsistent — an
extension using a capability the manifest never declared, a reserved namespace, a generator whose
outputKinds no step actually produces, an apiVersion the host can’t load. A plugin that imports
cleanly is a plugin that installs cleanly; there is no separate “lint your manifest” step to forget.
Shipping it
@acme/weather/ novaterra.plugin.json the manifest definePlugin produced, written to disk dist/plugin.js "main": what the host imports package.jsonimport { writeFileSync } from 'node:fs';import plugin from './src/plugin';writeFileSync('novaterra.plugin.json', JSON.stringify(plugin.manifest, null, 2));If everything the plugin contributes is decided at module scope — the normal case, as above — a
default export is all that’s needed. If a skill has to be built conditionally, export activate
instead:
export async function activate(host) { if (host.has('network')) host.registerSkill(await buildRemoteSkill()); host.log('ready');}host.registerSkill only accepts an id the manifest already declared — a plugin cannot add an
extension at runtime that the owner never saw on the install screen.
Reference
| Export | Use |
|---|---|
definePlugin(input) | Assemble, validate and freeze a plugin |
defineSkill, defineGenerator, defineWidget, defineIntegration, defineViewer, defineEditor, defineTheme | One per extension kind |
cap.* | Capability builders: cap.network, cap.filesystem, cap.process, cap.llm, cap.storage, cap.memory, cap.signals, cap.schedule, cap.integrations, cap.credits, cap.worldActing |
form, str, text, num, bool, pick, step, dedent | Generator authoring kit |
z | Zod, re-exported, so a plugin never ends up with two copies |
PluginManifest, validatePluginManifest, isPluginApiCompatible | The schemas, for anyone building install tooling |
CAPABILITY_SUMMARY, ALL_CAPABILITY_KINDS | For rendering an install screen |
Contract version: 1.0 (PLUGIN_API_VERSION). A plugin built for 1.0 runs on any 1.x host at
or above that minor; a major bump is a breaking change and the host refuses to load an incompatible
plugin rather than half-running it (isPluginApiCompatible()).
Before installing anything you didn’t write yourself, read Isolation and sandboxing — what’s actually enforced today is narrower than the manifest alone suggests.