Skip to main content

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:

  • discover reads the declared spec and returns named inputs, preparation-only configuration and destination settings.
  • run receives 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 fieldValue
Artifact typeapplication/vnd.stemma.plugin.v1
Bundle layer media typeapplication/vnd.stemma.plugin.bundle.v1.tar+zstd
Index platformThe 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.