Skip to content

Create and ship a custom plugin

A plugin can live in its own public or private repository. It needs no change in Heinzel core or heinzel-plugins. Start with one operation and one test; add settings, services and views when the feature needs them. The same operation supplies CLI, MCP and REST access.

This guide applies to Heinzel 0.9.2+ and its versioned developer toolkit. You need Python 3.12+, uv, Git and make. GitHub CLI (gh) supplies repository creation, private access and release dispatch. The examples use a fictional Harbor plugin; replace your-account with your GitHub user or organization.

If hz is not installed, get the SDK CLI with uv tool install 'heinzel>=0.9.2,<0.10'. This installs core without real plugins or a source checkout. Run gh auth login before creating a GitHub repository if you are not signed in.

Scaffold a standalone repository

From a directory outside an existing Git repository:

hz system plugin new heinzel-harbor --defaults --data plugin_name=harbor --data github_owner=your-account
cd heinzel-harbor
make setup
make check-fast

The default minimal preset creates a typed operation, a behavior test, installed-wheel acceptance, a small Makefile, owning CI/release workflows, README, AGENTS.md and CLAUDE.md. setup initializes Git and creates this plugin's isolated environment and dependency lock. It downloads a pinned developer toolkit and a compatible released core wheel; it never clones or tests core.

Use --preset contributions when you want a working example of settings, a service, a submodule, status/actions, navigation, a Today widget, notifications and a local view. Both presets use the same commands.

Read the generated AGENTS.md before asking a coding agent to change the plugin. CLAUDE.md points to the same instructions. The extension guide describes the available declarations and their limits.

Make the first useful change

Edit src/heinzel_harbor/__init__.py and tests/hello_test.py. The minimal declaration is:

from heinzel import Module

module = Module("harbor", "Useful Harbor operations.")


@module.operation
def hello(name: str = "Ada") -> str:
    """Greet someone by name."""
    return f"Hello, {name}!"

Return values rather than printing. Keep imports within your package and the public heinzel / heinzel.kernel API. If plugins share an interface, put that interface in the SDK or a separate implementation-free contract package. Consumers depend on that contract; they must not import a provider plugin.

make fix          # format and apply lint fixes
make check-fast   # own lint and unit behavior
make check        # own strict types, tests, coverage and frontend checks if declared
make preview      # isolated app state; stop with Ctrl-C
make build        # this plugin's wheel and source distribution
make acceptance   # install the wheel outside the source tree and test it

No unrelated plugin or core tests run. Write a test for the new behavior and mark every pytest file with one level: unit, integration or e2e. Installed acceptance belongs in tests/acceptance/. A richer frontend declares its own build and tests; a small static view needs no JavaScript build system.

Add dependencies

Add ordinary libraries and shared contract packages to project.dependencies in this plugin's pyproject.toml. Put test tools in its test dependency group, then run make lock and make check. Commit the owning lock file. The lock records resolved versions and hashes; it does not change core's dependencies or another plugin's lock.

Before a new SDK is released, use an explicit candidate artifact:

make lock CORE=/absolute/path/to/heinzel-0.9.1-py3-none-any.whl
make check CORE=/absolute/path/to/heinzel-0.9.1-py3-none-any.whl
make acceptance CORE=/absolute/path/to/heinzel-0.9.1-py3-none-any.whl

The toolkit checks the artifact's identity and compatibility range. Local wheel paths are not stored in the lock. After the compatible core is published, the same commands work without CORE. Set TOOLING=<toolkit-wheel> only when testing a candidate developer toolkit; normal projects use their immutable version and digest pin.

Choose public or private hosting

First commit the scaffold and your feature. Then create exactly one repository:

git add .
git commit -m "feat: add Harbor plugin"
gh repo create your-account/heinzel-harbor --public --source . --remote origin --push

For a private plugin, replace --public with --private. Keep the repository, examples, artifacts and credentials private. Both layouts and presets work in a repository outside heinzel-plugins.

Repository visibility controls source access. Operations returning private data must declare @module.operation(private=True) so only local or authenticated heads expose them. Hosting a repository privately does not change an operation's declared exposure. See the extension guide.

To participate in the catalog, add the heinzel-plugin topic:

gh repo edit your-account/heinzel-harbor --add-topic heinzel-plugin
hz system plugin search --text harbor --json

Discovery reads the repository's pyproject.toml and heinzel.modules entry point without importing its code. There is no central registration file to edit. Private repositories appear only to GitHub accounts allowed to read them; run gh auth login on each installing machine. The same authenticated gateway reads catalog listings and project metadata. A topic is optional for direct installation.

Catalog discovery currently uses GitHub. Direct Git installation supports HTTPS repositories; SSH URLs, archives and local paths are not plugin add sources. Use hz system plugin develop . for a local checkout.

Release one plugin

The plugin version belongs to this project, independently of core and other plugins. After the initial 0.0.0 scaffold, choose a release version and run the local release preview:

uv version --bump minor --frozen
make lock
make release

release runs owning checks, builds artifacts and previews the tag; it uploads nothing. Commit the version and lock changes, merge a reviewed pull request, and return to clean, current main. The generated CI checks this plugin against an installed core wheel. It does not run core's suite.

For a public PyPI package, configure a GitHub environment named pypi, restricted to main, and register a PyPI trusted publisher for this distribution, your repository, release.yml and that environment. Each distribution needs its own trust grant; no long-lived API token is needed.

When a collection has several brand-new PyPI projects, enroll and publish one pending project before enrolling the next. PyPI currently rejects duplicate pending GitHub publisher identities; activation removes that bootstrap restriction. Existing projects can publish independently in parallel. See the PyPI tracking issue.

With the owning publisher configured:

make publish

For a private plugin, the same command selects GitHub releases automatically. Private sources are refused by the PyPI path because PyPI packages are public. A public plugin can also choose GitHub-only publication with make publish REGISTRY=github.

The protected workflow checks clean source identity, builds once, runs acceptance on that wheel and publishes those exact bytes under heinzel-harbor/v<version>. Public artifacts carry GitHub provenance. Private repository attestations require a supported GitHub plan; enable PRIVATE_ATTESTATIONS=true as a repository variable only when available. See GitHub's availability and setup. Private checks keep package names out of public advisory queries. If your organization provides an internal audit tool, declare its argument list as tool.heinzel.plugin.audit_command; it runs in the owning environment. Without that command, private releases verify locks and installed compatibility but do not perform a vulnerability advisory audit.

If a public GitHub release was published before its PyPI grant was ready, resume its original upload:

make publish REGISTRY=pypi TAG=heinzel-harbor/v0.1.0

This verifies the original tag, source commit, workflow attestation and artifact hashes, then uploads the same bytes. It does not rebuild from newer main. Private releases do not use this public-registry resume path. Never overwrite a released version; change the owning version for a new release. uv publish --dry-run dist/* can rehearse artifact validation locally without uploading.

Install from another repository

Public and private GitHub plugins use the same command; private access uses the installing person's gh login:

gh auth login
hz system plugin add 'git+https://github.com/your-account/heinzel-harbor@heinzel-harbor/v0.1.0'
hz system plugin add 'git+https://github.com/your-account/heinzel-harbor@heinzel-harbor/v0.1.0' --confirm
hz harbor hello --name Ada --json

The first call previews the source and resolved commit. The second installs the pinned candidate. Do not put credentials in repository URLs or commit them. A published PyPI package can instead be installed with hz system plugin add heinzel-harbor --confirm. Private GitHub releases provide distributable artifacts and history; source installation from their immutable tag does not require a private package index.

Add to a plugin collection

Inside heinzel-plugins, or a collection using the same developer toolkit:

make new NAME=harbor
make check-fast NAME=harbor
make check NAME=harbor
make build NAME=harbor
make acceptance NAME=harbor
make release NAME=harbor
make publish NAME=harbor

The collection scaffold creates only plugins/heinzel-harbor/, with its own metadata, source, tests, lock and the same small owning Makefile. From inside that directory, the standalone commands above work unchanged. This is also the owning loop for optional plugins in Core or contrib: the parent Makefile may build something else. Root NAME shortcuts are conveniences of a configured collection.

The supported hosts route plugin-only hooks and CI to owning projects and public-contract consumers. Their one generic plugin release workflow supplies publication without core tests. The scaffold adds no nested Git repository, copied workflow or parent registration. To render collection files directly use hz system plugin new plugins/heinzel-harbor --defaults --layout collection --data plugin_name=harbor. Name the owner in root collection commands; shared tooling checks have the distinct tooling-check target. Consumers of a changed public contract are selected from their declared requirements, without testing all core. Core-root make pr and coding-agent completion checks also select the owning projects for plugin-only work. Shared commit and credential policies still apply; a mixed core/plugin change retains the full core preflight. Core's optional-plugin workflow uses the main-restricted plugin-pypi environment for public PyPI grants; standalone repositories, contrib and the official collection use pypi. Register the owning distribution with its host's release.yml and that environment. Core's separate tagged-core publisher remains in pypi.