A meta-plugin is a regular zhi plugin that internally launches and orchestrates one or more child plugins. From the host’s perspective a meta-plugin looks like any other plugin – it implements the same gRPC interface. Internally, it uses the SDK helpers to launch child processes and compose their behaviour.
The meta-plugin SDK provides three building blocks:
pkg/zhiplugin/
launch/
launch.go LaunchConfig, LaunchTransform, LaunchStore
options.go Option, WithLogger, WithIsolatedEnv, WithAuditMode
audit.go AuditBinary, PluginNameFromPath, binary validation
config/
delegate.go DelegatingPlugin (config)
compose.go MergedPlugin, MountedPlugin
transform/
delegate.go DelegatingPlugin (transform)
store/
delegate.go DelegatingPlugin (store)
compose.go MirroredPlugin
pkg/zhiplugin/launch provides functions for launching external plugin
binaries as child processes. It extracts the launch logic from the
internal engine so that meta-plugins can reuse the same mechanisms.
Each function returns the typed plugin interface, a cleanup function that kills the child process, and an error:
import "github.com/MrWong99/zhi/pkg/zhiplugin/launch"
// Launch a config plugin binary.
cfg, cleanup, err := launch.LaunchConfig("/path/to/zhi-config-foo", opts...)
defer cleanup()
// Launch a transform plugin binary.
tfm, cleanup, err := launch.LaunchTransform("/path/to/zhi-transform-bar", opts...)
defer cleanup()
// Launch a store plugin binary.
st, cleanup, err := launch.LaunchStore("/path/to/zhi-store-baz", opts...)
defer cleanup()
The caller must call the cleanup function when done to kill the
child process. Use defer cleanup() immediately after a successful
launch.
| Option | Purpose |
|---|---|
WithLogger(l) |
structured logger for audit results and lifecycle events |
WithIsolatedEnv(env) |
restrict child environment to handshake variables + extras |
WithAuditMode(mode) |
control how integrity check failures are handled |
By default, child processes inherit the parent’s full environment.
WithIsolatedEnv restricts the child to only ZHI_PLUGIN, PATH,
and the user-supplied map. This prevents leaking secrets or credentials
to children:
launch.LaunchStore(bin,
launch.WithIsolatedEnv(map[string]string{
"VAULT_ADDR": "https://vault.example.com",
"VAULT_TOKEN": os.Getenv("VAULT_TOKEN"),
}),
)
| Mode | Behaviour |
|---|---|
AuditHardFail (default) |
return an error if binary integrity check fails |
AuditWarnOnly |
log a warning but continue launching |
The public API defaults to AuditHardFail. The engine uses
AuditWarnOnly for backward compatibility.
Before launching a plugin, the launch package:
.. componentszhi plugin install)If the binary was not installed via the sharing system (e.g., locally-built plugins), the integrity check is skipped.
Child processes are launched in a separate process group
(Setpgid: true on Unix) so that signals sent to the parent do not
propagate directly to children. This ensures clean shutdown via the
cleanup function rather than accidental signal forwarding.
Each plugin type provides a DelegatingPlugin struct that implements
the full interface by forwarding every call to a Base plugin. Embed
this struct and override only the methods you need:
import "github.com/MrWong99/zhi/pkg/zhiplugin/config"
type auditingConfig struct {
config.DelegatingPlugin
log *slog.Logger
}
func (a *auditingConfig) Set(ctx context.Context, path string, v config.Value) error {
a.log.Info("config value changed", "path", path)
return a.Base.Set(ctx, path, v)
}
// List, Get, Validate are all forwarded to Base automatically.
import "github.com/MrWong99/zhi/pkg/zhiplugin/store"
type cachingStore struct {
store.DelegatingPlugin
cache map[string]map[string]config.Value
}
func (c *cachingStore) GetValues(ctx context.Context, id string, paths []string) (map[string]config.Value, error) {
// Check cache first, fall back to Base.
// ...
return c.Base.GetValues(ctx, id, paths)
}
// All other store methods (Capabilities, PutValues, ListTrees, etc.)
// are forwarded to Base automatically.
import "github.com/MrWong99/zhi/pkg/zhiplugin/transform"
type loggingTransform struct {
transform.DelegatingPlugin
log *slog.Logger
}
func (l *loggingTransform) BeforeDisplay(ctx context.Context, tree *config.Tree) error {
l.log.Info("transforming tree", "paths", len(tree.List()))
return l.Base.BeforeDisplay(ctx, tree)
}
// AfterSave and ValidatePolicy are forwarded to Base automatically.
Use the NewDelegatingPlugin constructor for each type. It panics if
base is nil:
base := config.NewDelegatingPlugin(somePlugin)
// or
base := store.NewDelegatingPlugin(somePlugin)
// or
base := transform.NewDelegatingPlugin(somePlugin)
Combines multiple config plugins under distinct path prefixes. Each child’s paths are namespaced by its mount point:
import "github.com/MrWong99/zhi/pkg/zhiplugin/config"
merged, err := config.MergedPlugin(
config.MountedPlugin{Impl: appA, Prefix: "app-a/"},
config.MountedPlugin{Impl: appB, Prefix: "app-b/"},
)
Behaviour:
| Operation | Routing |
|---|---|
List |
queries all children in parallel, prefixes each path |
Get |
routes to child matching the path prefix |
Set |
routes to child matching the path prefix |
Validate |
routes to child matching the prefix; provides a scoped TreeReader |
Constraints:
Impl values are rejectedThe scoped TreeReader passed to Validate strips the prefix so that
each child sees paths relative to its own namespace.
Writes to all stores but reads from the primary. Mirror write errors are logged but do not fail the operation:
import "github.com/MrWong99/zhi/pkg/zhiplugin/store"
composed := store.MirroredPlugin(logger, primary, mirror1, mirror2)
Behaviour:
| Operation | Routing |
|---|---|
Reads (GetValues, ListTrees, versioning reads) |
primary only |
Writes (PutValues, DeleteValues, DeleteTree) |
primary first, then mirrors in parallel |
Capabilities |
intersection of all stores (most restrictive wins) |
| Auth/Encryption/Access control | primary only |
Mirror writes use nil for PutOptions (no CAS on mirrors) to avoid
conflicts. If a mirror write fails, the error is logged but the
operation succeeds as long as the primary succeeded.
A meta-plugin is a standard plugin binary that uses the SDK to launch children and compose them. The host treats it like any other plugin.
Locate sibling binaries relative to your own executable, or look them
up on PATH:
selfPath, _ := os.Executable()
dir := filepath.Dir(selfPath)
primaryBin := filepath.Join(dir, "zhi-store-memory")
mirrorBin := filepath.Join(dir, "zhi-store-json")
primary, cleanupPrimary, err := launch.LaunchStore(primaryBin,
launch.WithLogger(logger),
)
if err != nil {
// handle error
}
defer cleanupPrimary()
mirror, cleanupMirror, err := launch.LaunchStore(mirrorBin,
launch.WithLogger(logger),
)
if err != nil {
// handle error
}
defer cleanupMirror()
composed := store.MirroredPlugin(logger, primary, mirror)
goplugin.Serve(&goplugin.ServeConfig{
HandshakeConfig: zhiplugin.Handshake,
Plugins: map[string]goplugin.Plugin{
"store": &store.GRPCPlugin{Impl: composed},
},
GRPCServer: goplugin.DefaultGRPCServer,
})
A meta-plugin’s zhi-plugin.yaml uses the standard format. The type
matches the interface the meta-plugin exposes to the host:
schemaVersion: "1"
name: mirror
type: store
version: 0.0.1
zhiProtocolVersion: "1"
description: Meta-plugin that mirrors writes to two child store plugins
keywords:
- store
- mirror
- meta-plugin
- composition
Meta-plugin binaries follow the same naming scheme as regular plugins:
zhi-<type>-<name> (e.g. zhi-store-mirror).
The examples/zhi-store-mirror/
directory contains a working meta-plugin that launches zhi-store-memory
as the primary and zhi-store-json as the mirror. Build and run it
with:
make build-examples
Both child plugin binaries must be available in the same directory as
the meta-plugin binary (or on PATH).
The examples/zhi-store-vault-manager/
directory demonstrates a more advanced meta-plugin that wraps
zhi-store-vault with automatic Vault credential management. It
uses DelegatingPlugin to override Login, GetValues, and
PutValues while forwarding all other store methods to the child.
Key techniques shown:
WithIsolatedEnv to prevent leaking the admin token to the childWithPluginOptions to forward connection options to the childstore.ephemeral label to inject credentials that are never persistedvault.app.* metadata labelsSee the Vault Credential Management guide for end-user documentation.
| Pattern | Use case |
|---|---|
| MirroredPlugin | backup/replication: fast in-memory primary with durable JSON file mirror |
| MergedPlugin | multi-tenant config: each tenant’s config plugin mounted under a prefix |
| DelegatingPlugin + Launch | add logging, caching, or access control around an existing plugin |
| DelegatingPlugin alone | override specific methods of any plugin without reimplementing the full interface |
| DelegatingPlugin + Launch + credential management | wrap a store with automatic policy reconciliation and credential injection (see vault-manager below) |
WithIsolatedEnv to avoid leaking secrets to child processesAuditHardFail mode rejects tampered binaries; use
AuditWarnOnly only during development