Creating plugins
A plugin is an executable speaking Stemma's JSON protocol. It can register a resource kind, source resolver or destination. The public Go SDK supplies the registry, request types and protocol framing used by built-ins. Other languages can implement the same wire contract.
Pin the SDK in your plugin's dependencies. A plugin works with any Stemma that implements its operation kinds at the same interface versions; see protocol and runtime.
Our catalog's downloads plugin is a working SDK example with typed resolvers, tests, Mise tasks and GoReleaser bundles. Its setup guide covers local builds and distribution. See using plugins for loading one into a catalog.
Register a resource kind
A resource has two methods:
discoverreads the declared spec and returns named inputs, preparation-only configuration and destination settings.runreceives concrete preparation configuration and locked inputs in a leased workspace, and returns named artifacts.
Separating destination settings from preparation configuration lets a metadata
edit reuse the existing artifact. Resolve downloads through inputs; do not hide
upstream discovery inside run.
This minimal VendorPackage kind passes a resolved PKG through to a destination.
It demonstrates input resolution and content handoff without constructing another
package. Save it as main.go in a plugin module:
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"github.com/woodleighschool/stemma/plugin"
)
type spec struct {
Source plugin.Input `json:"source" jsonschema_description:"Vendor installer to acquire."`
Destinations map[string]map[string]any `json:"destinations" jsonschema_description:"Native metadata for each named destination."`
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if err := serve(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func serve(ctx context.Context) error {
registry := plugin.New("catalog-tools", "1.0.0")
err := plugin.Register(registry, plugin.Operation{
Name: "catalog-tools.package",
Kind: "resource",
Resource: &plugin.ResourceKind{APIVersion: "example.org/v1", Kind: "VendorPackage"},
SideEffects: "workspace",
Methods: []string{"discover", "run"},
}, prepare)
if err != nil {
return err
}
return plugin.Serve(ctx, os.Stdin, os.Stdout, registry)
}
func prepare(ctx context.Context, request plugin.ResourceRequest[spec]) (plugin.ResourceResult, error) {
var result plugin.ResourceResult
switch request.Method {
case "discover":
result.Inputs = map[string]plugin.Input{"source": request.Config.Source}
result.Config = json.RawMessage(`{}`)
result.Destinations = request.Config.Destinations
case "run":
artifact := request.Inputs["source"]
if artifact.Tree || !strings.EqualFold(filepath.Ext(artifact.Filename), ".pkg") {
return result, errors.New("source must be a PKG file")
}
artifact.Format = "pkg"
result.Artifacts = map[string]plugin.Artifact{"installer": artifact}
plugin.Logger(ctx).InfoContext(ctx, "Prepared vendor package")
}
return result, nil
}
Initialise and build the module:
go mod init example.org/catalog-tools
go get github.com/woodleighschool/stemma@main
go build -o plugin .
On Windows, name the output plugin.exe. go get records the resolved revision in
go.mod; keep that pin. plugin.Register derives the request, config and response
schemas from the handler types. The protocol, MCP documentation and catalog editor
use those same contracts.
Typed configuration
Use Go fields for structure, omitempty for optional fields, and invopop's
jsonschema tags for constraints and defaults. Add jsonschema_description to
fields to explain their purpose, behaviour and constraints. These descriptions
are documentation for agents through the describe tool of stemma mcp, as well
as part of the generated JSON Schema. Explain what an agent needs to choose a
value correctly, including units, defaults and interactions with other fields.
type Architecture string
const (
ARM64 Architecture = "arm64"
X64 Architecture = "x64"
)
func (Architecture) JSONSchemaExtend(s *jsonschema.Schema) {
s.Enum = []any{ARM64, X64}
}
type Config struct {
Major int `json:"major" jsonschema:"minimum=1" jsonschema_description:"Major release to track."`
Architecture Architecture `json:"architecture,omitempty" jsonschema:"default=arm64" jsonschema_description:"Installer CPU architecture."`
}
Import github.com/invopop/jsonschema for the enum hook. Registration rejects
unknown fields, checks required fields and enum values, applies schema defaults,
then decodes the effective config. An optional Validate() error method handles
semantic rules before a resolver or destination handler runs, including locked
requests. Destination validate requests carry no connection settings and skip
these checks.
Resource discovery validates the declaration. stemma validate sends it as
written, so a field may hold an expression instead of its value; runs that acquire
the resource send environment values evaluated. Route values without interpreting
them, and check what a value means once it is concrete. Expression-bearing fields
are checked again after evaluation. run receives preparation config, without
source declarations or destination metadata. Resolver validate requests stop
after configuration checks and perform no acquisition. Direct calls to typed
resolvers supply effective config values.
Defaults live in tags once; omitted values receive them while explicit zero, false and empty values remain explicit. Required fields must be supplied even when they have a default annotation. Sparse destination metadata retains its absent/null/value semantics and does not receive config defaults.
JSON Schema describes structural constraints. Cross-field and external-system
rules may remain authoritative in Go; use JSONSchemaExtend only when a small
schema addition makes the contract clearer to agents and editors. stemma schema --output-file stemma.schema.json composes the
static document structure with every locally loaded operation. No copy of a
plugin's config fields belongs in the catalog schema generator.
Run it from a catalog
Copy the executable into plugins/catalog-tools/ in your catalog and add this
Project entry:
spec:
plugins:
catalog-tools:
path: plugins/catalog-tools
With the munki connection from getting started, add a
resource and a real vendor package at Assets/Vendor.pkg beside its document:
apiVersion: example.org/v1
kind: VendorPackage
metadata:
name: vendor-app
spec:
source:
path: Assets/Vendor.pkg
destinations:
munki:
pkginfo:
description: Vendor application.
stemma plugins update catalog-tools
stemma validate
stemma update VendorPackage/vendor-app
stemma prepare VendorPackage/vendor-app
stemma plan VendorPackage/vendor-app
The native Munki destination accepts the PKG and inspects it through the ordinary
artifact path. It does not need to know VendorPackage exists. A consumer can also
reference this resource's installer output using its full apiVersion, kind,
name and output.
Artifact and evidence handoff
plugin.Artifact describes a file or tree with its path, filename, format, content
digest and size. It can include an entry point, selected version, typed Facts
and namespaced JSON Evidence.
Create new outputs inside request.Workspace. Treat leased inputs as immutable;
the host recomputes identity, checks declared hashes and detects input mutation.
Do not retain workspace paths between requests or reach into the host's cache.
Runs share one installed copy of a bundle, so do not write beside the executable.
Plugin bundle identity participates in preparation cache keys.
Resource operations can advertise TarInputs to receive trees as private
canonical TAR files. These inputs have Encoding: "tar"; Filename, Mode and
Tree describe the logical root directory, while Path names the leased TAR.
The digest covers the TAR's bytes, member modes and symlink targets. Without this
capability, tree inputs are leased as directories. Outputs must be ordinary files
or directories.
Canonical trees contain regular files, directories and confined relative
symlinks, with normalized timestamps and ownership. The root directory's mode
is carried separately in Mode. This transport does not preserve arbitrary host
metadata; inputs with unsupported signature metadata are rejected.
Typed facts retain separate subjects: a package receipt, contained application or
MSI can each have its own identity and version. Paths describe artifact provenance;
installed paths describe endpoint locations. Optional evidence such as
com.example.inspection can carry additional JSON without extending a closed core
model. Consumers must understand evidence before using it.
Destination descriptors use ContentContract to accept formats, trees and/or
source-free content. This is independent of originating kind names. A destination
must still validate its native deployment mode: accepting a PKG does not mean it
accepts Munki nopkg or an arbitrary script policy.
A resource's committed PNG arrives as the icon artifact in reconciliation
inputs. Destinations publish those bytes independently of installer uploads:
create a missing icon, replace changed content and retain the published icon
when no input is supplied. apply always receives the icon with its Path, so a
destination can restore artwork its own writes displaced; validate and plan
receive its identity.
Resolvers
Resolver plugins should model discovery or acquisition semantics that built-in sources cannot express cleanly. When a built-in gains equivalent behaviour, catalogs can remove the plugin instead of preserving a compatibility layer.
Register an operation with kind: resolve, methods validate, discover and
run, and a ResolverKind containing the observation contract's version. Set
local: true when consuming a lock must also check current local files.
ResolveRequest[Config] supplies declaration config, resource-relative base
and project root. discover returns the current observation without
downloading anything. run receives an observation and a workspace and
returns a transient download request or the artifact it names, reproducing that observation instead of asking
for the latest release. Stemma owns the surrounding versioned lock, declaration
fingerprint and content verification; your resolver owns the observation body.
Keep credentials out of observations and identify credential configuration
fields with writeOnly: true.
Set immutable when an observation always fetches the same bytes, such as a
vendor release ID. Stemma then reuses what it fetched for that observation, or
what the lock records for it, instead of calling run. Reuse from the cache is
per plugin build; the lock's answer holds across builds. Change the version
when an observation's meaning changes.
When discovery identifies the bytes, return content with sha256, filename,
mode and optional tree. No size is required: these are expected bytes, not a
verified artifact. To delegate HTTP acquisition, return download from run
with url, optional headers, filename and an optional expected sha256.
These requests may contain current credentials or signed URLs; they are never
written to locks. Reconstruct and validate the request from the saved observation
and current configuration on every run. Return exactly one download request or
an artifact inside the leased workspace. Discovery must not return either.
The host confines credentials and custom headers to the request origin, rejects HTTPS downgrades, acquires mutable content conditionally, and verifies consumed content against the lock. The resolver owns observation validation, filename selection, and provider authentication. All downloads use the same transfer and cache lifecycle.
Return registry metadata in namespaced discovery evidence, for example
{"vendor.release":{"version":"1.2"}}. Evidence is reviewed in the source lock
and passed to resource inputs; destination metadata can reference it with
{{ evidence['vendor.release'].version }}. A builder reads the same evidence
through inputs.<name>.evidence. Fresh discovery evidence replaces the prior
evidence independently of cached bytes. Changing evidence invalidates preparation
even when the bytes are unchanged.
Discovery can also return version and content_root. The version is available
as inputs.<name>.version. A content root is a relative directory within the
input tree, archive or disk image; it scopes $input selections, including an
omitted path. Keep source-format knowledge here and installation layout in the
consuming resource. Both values are reviewed in the lock and reused on frozen
runs. They do not replace the digest of the original bytes.
Observation remains private to the resolver. A locked fetch verifies bytes and uses the lock's saved evidence, ignoring evidence returned by the fetch. Resolver artifact formats and typed facts are not persisted; resource kinds own their interpretation of the downloaded content. Supply the input version during discovery. Keep credentials and temporary URLs out of evidence as well as observations.
Destinations
Register kind: reconcile with methods validate, plan and apply. Use
ConfigSchema for connection settings and MetadataSchema for native
settings. ReconcileRequest[Config] includes logical identity, the primary
artifact with its facts and managed Version, named artifact inputs, peers and,
for macOS software, MinimumOS: the software's minimum_os, or else the latest
of the installer's requirement and the selected application's, with the origin of
the value.
validate checks metadata, and the prepared artifact when there is one, without
connecting: it receives no Config. plan and apply receive the connection
settings with their environment values evaluated.
For a prepared artifact, validate also answers with what it will publish.
Metadata is the request's metadata in the declaration's own shape, with every
value the destination derives from the artifact and null or an empty value for
each field it clears; Origins maps each field to where its value came from. Leave out anything the destination assigns, such as a
stored file's location. stemma prepare --json reports both, so return
Metadata only when the derived state reads in the declaration's terms.
Artifact identity, size, format, facts and evidence are available without Path.
When a method needs bytes, return ReconcileResponse.Content selecting Artifact
or named Inputs, before making any writes. The engine retries that method once
with verified private copies of all requested content. These paths expire when
the invocation returns. Request only the inputs the method actually needs;
apply already holds the icon.
Keep metadata-only plans and updates independent of installer bytes. Apply must re-observe remote state and request content if an upload becomes necessary after plan. Verify bytes against their declared identity when publishing, and use the storage service's transfer checksum.
plan reads the destination and returns semantic Change records without
mutations. apply re-observes and performs the necessary changes. Preserve absent,
null, false and empty collection values when decoding native metadata; they have
different meanings. Return the changes already made alongside an error.
Identify publications from native keys or a marker in a remote field. Re-running with an empty local cache must find existing objects and skip completed uploads. After an interruption, re-read the destination before writing again.
Explicit fields override derived values. Missing derived values clear owned
fields; other omitted fields remain unchanged. Ownership follows the current
declaration and artifact.
Within destination metadata, resource is reserved for an explicit
ResourceReference containing apiVersion, kind and name. A destination's
metadata schema defines where that reference is accepted and which native
relationship properties may accompany it. Stemma reads these references directly,
validates the publication graph, and reconciles selected peers first.
Identity.Resource identifies the current resource. Peers maps each referenced
resource's ResourceReference.Key() to its declared metadata for this connection,
including unselected peers. The key defaults the API version to stemma/v1alpha1.
Resolve native IDs from that metadata and the resource identity; missing peers are
errors. Never reinterpret resource names as native strings or remote search terms.
ReconcileResponse reports changes and origins; it does not discover dependencies.
ResourceOutputReference adds an output selector to resource identity for immutable
preparation inputs. It is distinct from publication references, which have no
output selector. See the two graphs.
Use the shared Retention type and order a software's publications from the
destination's own records, while implementing native reference checks and cleanup in
the provider. See retention.
Protocol and runtime
Plugins inherit the host process environment. Use system HTTPS trust and honour
the runner's SSL_CERT_FILE and SSL_CERT_DIR overrides; keep CA configuration
out of operation schemas. Go 1.27+ HTTP clients provide this behaviour by default.
Clients in other runtimes must honour the same contract. See
HTTPS trust.
The host launches an executable for one request: one JSON object on stdin,
ending at EOF. The last stdout line is the response, with optional output and
optional error. Before it, a plugin may emit newline-delimited messages
containing log, a structured record with time, level and message. Messages are
bounded to 4 MiB, and nothing may follow the response. The SDK's Serve and
Run handle framing and validation; a request a plugin cannot handle still gets
an error response.
Stemma describes every plugin it loads. The request carries only the method and is always answered:
{ "method": "describe" }
The output is a Descriptor: provider name and version, the VCS revision
recorded in the executable's build information, interfaces and operations.
interfaces names the version of each operation kind the plugin implements,
such as {"resolve": 2}; the SDK fills it in. Stemma uses a kind's operations
only while that version matches its own. Operations of a kind at another version
are unavailable: commands that do not use them are unaffected, and those that do
fail naming both versions. The describe exchange, those identity fields and each
operation's name, kind and resource keep their meaning across versions, so
any Stemma can report what a plugin built for another one offers.
Other requests add operation, input and optionally log_level.
Reserve stdout for the protocol. Use plugin.Logger(ctx) for structured diagnostics
and plugin.Stage(ctx, "Downloading installer", plugin.Detail(filename)) for
progress; call the returned function with the operation error, and optionally
plugin.Detail(version), when the stage finishes. A detail names what the stage
works on, then what it found. Supply plain labels and details without status
markers, colour codes or alignment padding; the host formats their presentation.
The host supplies resource, phase and destination context; plugins report the
operations they perform. The live display shows the innermost running
stage. Completed transfers and other finished stages that ran for a second or
longer stay listed until the resource's result is written, unless other stages
ran inside them. Finish a transfer stage before starting finalization so each
keeps its own timing and the completed transfer retains its byte count. Its line shows
both details, or the second alone when it contains the first. A stage that
starts again in the same phase with the same message and detail continues that line and adds to
its time. Wrap transfers in
plugin.ProgressReader to show their bytes. Raw subprocess stderr is discarded
by the host, so report operational failures through the protocol. Do not log
credentials, query strings or request bodies.
Resource discovery can return ResourceResult.Notices for actionable declaration
advice. Each notice has a level (warning or info), stable code, message and
optional hint. Notices do not fail the resource and stay outside preparation
identity, so they remain current on cache hits. A discovery request's derive
field identifies a policy being derived; omit advice to derive that same policy.
Platforms contains supported runner GOOS/GOARCH pairs; omission means portable.
Requirements describes commands, purpose and actionable setup instructions.
Declare dependencies on interpreters, signing tools or packaging helpers. Scripts
intended for installation remain endpoint payload, not runner commands.
Distribute a bundle
Local files are a first-class distribution option. A published plugin is an OCI
platform index with one manifest per runner platform. Each manifest carries a
tar.zst bundle with plugin or plugin.exe and its helper files at the archive
root:
| OCI field | Value |
|---|---|
| Artifact type | application/vnd.stemma.plugin.v1 |
| Bundle layer media type | application/vnd.stemma.plugin.bundle.v1.tar+zstd |
| Index platform | The runner's OS and architecture |
Stemma pins the index and fetches only the selected runner's bundle. All code runs with the caller's privileges; the protocol is not a sandbox.
Release a Go plugin
GoReleaser builds the bundles and Stemma publishes them. Build a plugin
executable for each runner platform and archive it as tar.zst:
version: 2
builds:
- binary: plugin
env:
- CGO_ENABLED=0
goos: [darwin, linux, windows]
goarch: [amd64, arm64]
archives:
- formats: [tar.zst]
After GoReleaser runs, publish the archives it lists in dist/artifacts.json from
the same directory:
stemma plugins publish ghcr.io/example/catalog-tools:1.0.0 --goreleaser dist
The archives must come from one GoReleaser archive id and all be tar.zst. When
more than one archive id builds tar.zst, name the plugin's with --goreleaser-id.
Use baseline CPU targets: goamd64: [v1] and goarm64: [v8.0]. Stemma selects
bundles by OS and architecture without detecting CPU extensions.
publish checks each bundle the way the loader will, pushes a manifest for each
platform and tags their index last, so a failed run tags nothing. A published tag
keeps its release: publishing the same bundles and annotations again changes
nothing, and changes need a new tag. Registry credentials come from docker login.
The index carries the version and commit GoReleaser recorded as
org.opencontainers.image.version and org.opencontainers.image.revision. Add
other annotations with --annotation KEY=VALUE, such as
org.opencontainers.image.source for the repository URL. In GitHub Actions, the
publish-plugin action
runs this step after GoReleaser.