Skip to main content

Plugin model: the in-process registry contract

This implementation note covers the in-process registry mechanism a plugin binary uses to wire its features into the shared cli.App (an alias of the host module's host.App, the cobra-free application runtime the cli shell wraps). Plugin packages are blank-imported by a plugin binary's main; their init() functions register features against process-global registries via direct function calls: no gRPC, no dynamic loading inside the binary.

This is one half of the plugin story: how the Go code inside a plugin binary is composed. How kapi then discovers that binary on disk and dispatches to it at runtime (the manifest.json model and the A/B/C transport modes) lives in E-05: Plugin System. The kapi binary itself links no vendor plugins; the registries below populate inside the plugin binary; in the worked example below that is kapi-gitlab (built from gitlab-plugin/cmd/kapi-gitlab/).

This note is the reference for: how the registries work, how to write the Go side of a new plugin, and when to use the schema-only registry vs. the heavier ones.

When to use which registry

The framework, the host module, and the shared CLI module expose four registries. A plugin can use any subset. (cli.RegisterMCPToolFactory is a re-export of host.RegisterMCPToolFactory. MCP tools are cobra-free and live in the host module; the two command-facing registries are cli-native.)

RegistryLives inPlugin extends with
core/project.RegisterExtensionframeworkRecipe schema (typed YAML decoders for keys under Extras)
cli.RegisterCommandFactoryshared CLI moduleTop-level cobra subcommands (push, pull, …)
cli.RegisterAppInitializershared CLI moduleMutates *cli.App after construction (sets fields, wires hooks)
cli.RegisterMCPToolFactoryshared CLI moduleMCP tools served by the shared mcp subcommand

A plugin that only declares schema can be a tiny module with just one decoder file and no CLI deps; that is how a schema-only plugin module (just a schema/ package) is structured. A plugin that adds full UX (commands, MCP tools, source connector) layers more on top, but the schema part can ship independently.

The schema registry

type ExtensionDecoder interface {
Decode(node yaml.Node) error
}

type Scope int // ScopeProject | ScopeDefaults | ScopeCollection | ScopeItem

type Extension struct {
Name string
Scope Scope
Group string
Decoder ExtensionDecoder
DependsOn string
Venue bool
}

func RegisterExtension(ext Extension)
func RegisterExtensionGroup(group string, exts []Extension)
func HasExtensionGroup(group string) bool

Scope decides which Extras map a key is matched against:

  • ScopeProject: top-level keys on the recipe (a plugin's own key, such as gitlab:)
  • ScopeDefaults: keys nested under defaults:
  • ScopeCollection: keys on a Collection (named-collection wrapper or bare entry)
  • ScopeItem: keys on a ContentItem (per-item fields)

Re-registering the same (Scope, Name) pair panics: competing init functions are almost always a bug. Pure-name matches across scopes don't conflict (collection at ScopeItem and ScopeDefaults are distinct).

Group lets a recipe declare requires: { gitlab: "*" } and have validation fail when no extension under that group has been registered. Use this when a recipe is meaningless without that plugin's behavior: gitlab-push won't work without the gitlab plugin installed, so a recipe using the gitlab: block typically declares requires: { gitlab: "*" }. The map form is mandatory: each entry maps a plugin name to a semver constraint, with "*" meaning any version. The bare-list form (requires: [gitlab]) is rejected with an actionable error.

HasExtensionGroup is consulted by KapiProject.Validate() to enforce requires:. Plugins typically don't need to call it directly.

DependsOn names a sibling key at the same scope that must be present for this one to have any effect: a plugin's dependent keys depend on its own top-level key. A recipe that sets the dependent key without its dependency parses fine; the host reports the field as inert rather than failing the load.

Venue: true marks the key as the recipe's binding to a remote convergence venue. The framework reads url: and converge: out of it through KapiProject.Venue() and asks the registry which extension holds the flag rather than looking for a key by name: kapi links no platform and holds no opinion about a key it cannot name. VenueKey() answers the same question with no recipe in hand, so a message about an absent block can still name the block to add. At most one key across all installed plugins should claim it.

Forward compatibility

A recipe with an unknown extension key (no decoder registered for (scope, key)) loads successfully; the value sits in Extras and round-trips through Save. This is intentional: a kapi binary built without a given plugin can still load a recipe that uses it (it just can't validate or act on it). The requires: declaration is the recipe author's opt-in for "fail loudly if the extension is missing."

The CLI registries

type CommandFactory func(parent *cobra.Command, app *App)
type AppInitializer func(app *App)
type MCPToolFactory func(server *mcp.Server, app *App)

func RegisterCommandFactory(f CommandFactory)
func RegisterAppInitializer(f AppInitializer)
func RegisterMCPToolFactory(f MCPToolFactory)

CommandFactory is invoked by the plugin binary once after the built-in command tree is constructed. For the worked example that happens inside kapi-gitlab, which builds its command subtree from the registered factories:

// gitlab-plugin/cmd/kapi-gitlab/main.go
cli.ApplyCommandFactories(cmd, app)

Plugins typically register one factory per command file, e.g. gitlab-plugin/commands/push.go:

func init() {
cli.RegisterCommandFactory(func(parent *cobra.Command, a *cli.App) {
parent.AddCommand(buildPushCmd(a))
})
}

AppInitializer is invoked from the host's PersistentPreRun after app.Init(). Plugins use it to install fields like app.FallbackRunE (project flow resolution) that need to see the fully-initialized App.

MCPToolFactory is invoked by the shared app.NewMCPCmd("kapi") when the mcp subcommand starts. Each registered factory is given the *mcp.Server and the *cli.App and adds its tools.

One agent-facing surface

The registries above run inside one binary. A plugin ships as its own binary, so its MCP tools would sit on their own server, reachable only by a client configured to launch it. kapi mcp closes that gap: it spawns each installed plugin's mcp-server subcommand once per session and forwards tools/call to it, so an agent connected to kapi reaches a plugin's tools through the same surface as kapi's own. What a surface offers an agent is what it offers a human, and kapi push has a human form already.

host/mcp_plugin_proxy.go holds it, registered through RegisterLateMCPToolFactory so it runs after every ordinary factory and can read back the finished core surface. Four rules make it predictable:

  • The manifest is the contribution list. Only the tools a plugin declares under mcp_tools are proxied. A plugin binary built on the shared CLI serves the whole core surface as well, so proxying its full tools/list would publish a second copy of every kapi tool. Names come from the manifest; the input schema and description come from the plugin's live server, because the manifest carries neither.
  • kapi wins a name collision. The SDK's AddTool replaces a tool of the same name without saying so, so the proxy reads the server's tool names first and skips a plugin tool that would take one over. The skip is reported on stderr.
  • A plugin that does not answer is left off. A binary that fails to start, or has not listed its tools inside the startup bound, contributes nothing and the session continues. Other plugins are unaffected.
  • The sessions belong to the run. Each spawned server is held for the life of the MCP session and closed by App.Shutdown.

kapi links no plugin's code here either. The proxy speaks MCP to a subprocess over stdio, so a plugin's dependencies and its licence stay inside its own binary.

Writing a new plugin: a worked example

Suppose you want to add a gitlab source connector that pushes content to a GitLab repo as locale-suffixed branches. The plugin:

gitlab-plugin/
├── go.mod
├── plugin.go // anchor: imports the sub-packages
├── schema/
│ ├── extension.go // init() registers RegisterExtensionGroup("gitlab", ...)
│ └── server.go // GitLabServer { URL, Token, Branch, ... } + Validate
└── commands/
└── push.go // init() registers a "gitlab-push" command factory

gitlab-plugin/schema/server.go

package schema

import (
"errors"
"net/url"
"gopkg.in/yaml.v3"
)

type GitLabServer struct {
URL string `yaml:"url"`
Branch string `yaml:"branch,omitempty"`
}

func (s *GitLabServer) Validate() error {
if s == nil { return nil }
u, err := url.Parse(s.URL)
if err != nil || u.Scheme == "" {
return errors.New("url is required and must be a valid URL")
}
return nil
}

gitlab-plugin/schema/extension.go

package schema

import (
coreproj "github.com/neokapi/neokapi/core/project"
"gopkg.in/yaml.v3"
)

const Group = "gitlab"

func init() {
coreproj.RegisterExtensionGroup(Group, []coreproj.Extension{
{
Name: "gitlab",
Scope: coreproj.ScopeProject,
Decoder: coreproj.ExtensionDecoderFunc(func(n yaml.Node) error {
var s GitLabServer
if err := n.Decode(&s); err != nil { return err }
return s.Validate()
}),
},
})
}

Recipe

version: v1
name: my-app
requires:
gitlab: "*"
gitlab:
url: https://gitlab.example.com/team/project
branch: main
collections:
- path: src/locales/**/*.json
format: json

When loaded by a binary that links gitlab-plugin/schema (the plugin binary, or a desktop app that blank-imports the schema for validation), the recipe validates. When loaded by a kapi with no gitlab plugin installed, the recipe fails to validate:

invalid project file: recipe requires plugin "gitlab" (*) but no matching extension is registered (install with `kapi plugin install gitlab`)

Adding a command

// gitlab-plugin/commands/push.go
package commands

import (
"github.com/neokapi/neokapi/cli"
"github.com/spf13/cobra"
)

func init() {
cli.RegisterCommandFactory(func(parent *cobra.Command, a *cli.App) {
parent.AddCommand(buildGitLabPushCmd(a))
})
}

func buildGitLabPushCmd(a *cli.App) *cobra.Command {
return &cobra.Command{
Use: "gitlab-push",
Short: "Push translations to a GitLab project",
RunE: func(cmd *cobra.Command, args []string) error {
// ... implementation that loads the recipe and pushes
return nil
},
}
}

To ship the plugin, build it as its own binary that blank-imports the anchor, alongside a manifest.json declaring the gitlab-push command (and any formats/tools/connectors). kapi discovers the binary and dispatches to it:

// gitlab-plugin/cmd/kapi-gitlab/main.go
package main

import _ "example.com/gitlab-plugin"

Packaging and the license boundary

A plugin ships as its own binary plus a manifest.json, installed into a kapi plugin directory rather than linked into kapi. Because the plugin runs as a separate process, its license is independent of kapi's: the kapi binary stays Apache-2.0 and links no plugin's code, while a plugin binary (e.g. kapi-gitlab) carries that plugin's packages under its own license. The independence runs both ways: a plugin may be licensed more restrictively than kapi without affecting it, and a plugin that is not is still a separate binary reached the same way. There is no -tags pure / kapi-pure split: kapi is always plugin-free, and plugins are something you install into it.

See E-05: Plugin System for the manifest schema, discovery precedence, install paths (kapi plugin install <name>, Homebrew), and the A/B/C transport modes.

Initialization order

Within one Go binary:

  1. All init() functions run during binary startup, in package import order. Cross-package init order is undefined; the registries are append-only, so order shouldn't matter for correctness.
  2. The binary's main() constructs *cli.App, calls app.InitRegistries() (built-in formats/tools).
  3. Cobra's init() builds the command tree; the host calls cli.ApplyCommandFactories(root, app) after.
  4. cobra.Execute() runs. PersistentPreRun calls app.Init() then cli.ApplyAppInitializers(app).
  5. The chosen subcommand's RunE runs.

AppInitializer runs after Init, so plugins can rely on the registries (FormatReg, ToolReg, Credentials, Config) being populated. CommandFactory runs at init()-time of the host's main package; registries must be ready by then, which is why InitRegistries runs at cobra init too.

Goroutines and async work

Plugins that need their own background goroutines can spawn them inside their commands or connectors; the registries impose no model. A source connector, for example, can run concurrent block uploads via errgroup inside its Push. Nothing in the plugin contract requires the plugin to expose its async work to the host.

Testing plugins

Unit-test the schema decoders directly:

func TestGitLabServerDecodes(t *testing.T) {
var node yaml.Node
require.NoError(t, yaml.Unmarshal([]byte(`url: https://gitlab.example.com/team/proj`), &node))
var s GitLabServer
require.NoError(t, node.Decode(&s))
require.NoError(t, s.Validate())
}

Tests that mutate the global registries should call coreproj.ResetExtensionsForTest() and cli.ResetPluginRegistriesForTest() in setup so they don't leak state across tests.

Relationship to runtime dispatch

The registries on this page are purely in-process: they assemble the command tree, MCP tools, and recipe schema inside a single plugin binary. They say nothing about how that binary is found or invoked; that is the runtime concern owned by E-05: Plugin System: manifest.json discovery and the A/B/C transport modes (one-shot command exec, an MCP-over-stdio session, and the gRPC daemon used by formats/tools/source connectors, including the Java Okapi bridge).

In short: this note is the contract for the Go inside a plugin; E-05 is the contract for the process around it.