Skip to content

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:

  • input and output are zod schemas, and run is fully typed from them. input arrives already parsed — no casts, no unknown narrowing at the top of the function. The return value is validated against output on 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.signal goes into every fetch. 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 dependsOn an earlier step;
  • every outputKind the generator declares must actually be produced by some step;
  • {{steps.x.output}} in an instruction requires dependsOn: ['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.json
import { 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

ExportUse
definePlugin(input)Assemble, validate and freeze a plugin
defineSkill, defineGenerator, defineWidget, defineIntegration, defineViewer, defineEditor, defineThemeOne 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, dedentGenerator authoring kit
zZod, re-exported, so a plugin never ends up with two copies
PluginManifest, validatePluginManifest, isPluginApiCompatibleThe schemas, for anyone building install tooling
CAPABILITY_SUMMARY, ALL_CAPABILITY_KINDSFor 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.