zhi

Java Plugin Development

This guide explains how to build zhi plugins in Java. It covers the hashicorp/go-plugin wire protocol, gRPC service implementation, and building a native binary with GraalVM native-image.

The running example is a config plugin, but the same patterns apply to all four plugin types (config, transform, store, UI).

Prerequisite knowledge: read the Plugin Development Overview and the Config Plugin API first.

Prerequisites

Tool Version Notes
JDK 21+ Any distribution (Temurin, GraalVM, etc.)
Gradle 8.x The wrapper (gradlew) downloads it automatically
GraalVM native-image 21+ Only needed for native builds; JVM works for development

Project layout

zhi-config-javabean/
├── build.gradle.kts                 Gradle build (protobuf, gRPC, native-image)
├── settings.gradle.kts
├── src/main/java/dev/zhi/plugin/javabean/
│   ├── Main.java                    go-plugin handshake + gRPC server startup
│   ├── ConfigServiceImpl.java       gRPC ConfigService implementation
│   ├── BeanReflector.java           annotation-driven bean ↔ config-tree adapter
│   ├── annotation/
│   │   ├── ConfigPrefix.java        class-level path prefix
│   │   ├── ConfigProperty.java      field-level path/description override
│   │   └── WarnConstraint.java      meta-annotation for non-blocking violations
│   └── model/
│       ├── DatabaseConfig.java      example bean with Bean Validation
│       ├── SslRequiredForRemoteHost.java       custom cross-value constraint
│       └── SslRequiredForRemoteHostValidator.java
├── src/main/resources/META-INF/native-image/
│   └── javabean-plugin/
│       ├── reflect-config.json      GraalVM reflection entries
│       └── resource-config.json     GraalVM resource entries
└── src/test/java/…
    └── ConfigServiceImplTest.java   in-process gRPC tests

Gradle setup

Plugins

plugins {
    java
    application
    id("com.google.protobuf") version "0.9.4"
    id("org.graalvm.buildtools.native") version "0.10.4"
}

Key dependencies

val grpcVersion = "1.68.2"
val protobufVersion = "4.28.3"

dependencies {
    implementation(platform("io.grpc:grpc-bom:$grpcVersion"))
    implementation("io.grpc:grpc-netty")
    implementation("io.grpc:grpc-protobuf")
    implementation("io.grpc:grpc-stub")
    implementation("io.grpc:grpc-services")       // gRPC health check

    implementation("jakarta.validation:jakarta.validation-api:3.1.0")
    implementation("org.hibernate.validator:hibernate-validator:8.0.1.Final")
}

Referencing the proto files

Rather than copying the .proto definitions, point the protobuf plugin at the repository’s canonical proto directory:

sourceSets {
    main {
        proto {
            srcDir("../../api/proto")
            include("zhiplugin/v1/config.proto")   // only what you need
        }
    }
}

The generated Java stubs land in build/generated/source/proto/ and are added to the compile classpath automatically.

The go-plugin wire protocol

hashicorp/go-plugin is language-agnostic. A plugin binary must:

  1. Check the magic cookie — the host sets an environment variable (ZHI_PLUGIN=zhiplugin-v1). If it is missing or wrong, print a user-friendly error and exit.
  2. Start a gRPC server on a random TCP port. Include the standard gRPC health service — the host uses it for keep-alive probes.
  3. Write a single line to stdout in the format:

    <core_protocol_version>|<app_protocol_version>|tcp|<host:port>|grpc
    

    For zhi both protocol versions are 1, so the line looks like: 1|1|tcp|127.0.0.1:54321|grpc.

  4. Monitor stdin — when the host closes its end (or crashes), the plugin should shut down cleanly.

Java implementation

// 1. Verify magic cookie
String cookie = System.getenv("ZHI_PLUGIN");
if (!"zhiplugin-v1".equals(cookie)) {
    System.err.println("This binary is a zhi plugin.");
    System.exit(1);
}

// 2. Start gRPC server
int port = findFreePort();
Server server = ServerBuilder.forPort(port)
        .addService(myConfigService)
        .addService(new HealthStatusManager().getHealthService())
        .build().start();

// 3. Print handshake line
System.out.printf("1|1|tcp|127.0.0.1:%d|grpc%n", port);
System.out.flush();

// 4. Watch stdin for EOF
new Thread(() -> {
    try { while (System.in.read() != -1); }
    catch (IOException ignored) {}
    server.shutdown();
}, "stdin-watcher").start();

server.awaitTermination();

The helper findFreePort() opens a ServerSocket(0), reads its local port, and closes it immediately.

Implementing ConfigService

The proto service generated by protoc-gen-grpc-java is ConfigServiceGrpc.ConfigServiceImplBase. Override the four RPCs:

RPC Purpose
list Return every path this plugin manages
get Retrieve a single JSON-encoded value + metadata
set Store a JSON-encoded value at a path
validate Validate one path against the full tree snapshot

Values cross the wire as JSON-encoded bytes (ByteString). A string value "hello" is sent as the bytes "hello" (with quotes); the number 42 is sent as 42.

Delegation pattern

The example uses BeanReflector to translate between Java bean fields and the flat path model. ConfigServiceImpl simply delegates:

public class ConfigServiceImpl extends ConfigServiceGrpc.ConfigServiceImplBase {
    private final BeanReflector<?>[] reflectors;

    @Override
    public void list(ListRequest req, StreamObserver<ListResponse> obs) {
        var b = ListResponse.newBuilder();
        for (var r : reflectors) b.addAllPaths(r.listPaths());
        obs.onNext(b.build());
        obs.onCompleted();
    }
    // ... get, set, validate follow the same pattern
}

To manage multiple configuration groups, pass multiple BeanReflector instances — each bean class owns a disjoint set of paths.

Mapping Java beans to configuration paths

The BeanReflector<T> class uses three annotations to map bean fields to zhi paths:

Annotation Target Purpose
@ConfigPrefix("database") Class Sets the first path segment
@ConfigProperty(path="ssl-enabled", description="...") Field Overrides the path segment and/or adds metadata
@WarnConstraint Annotation type Makes violations from the annotated constraint non-blocking

Fields without @ConfigProperty are converted to kebab-case automatically (maxConnectionsmax-connections).

Example bean

@ConfigPrefix("database")
@SslRequiredForRemoteHost          // custom cross-value constraint
public class DatabaseConfig {

    @NotBlank(message = "database host is required")
    @ConfigProperty(description = "Database server hostname")
    private String host = "localhost";

    @Min(value = 1, message = "port must be at least 1")
    @Max(value = 65535, message = "port must be at most 65535")
    @ConfigProperty(description = "Database server port")
    private int port = 5432;

    @ConfigProperty(path = "ssl-enabled",
                    description = "Whether TLS is used")
    private boolean sslEnabled = false;

    // ... more fields, getters
}

This produces paths: database/host, database/port, …, database/ssl-enabled.

Bean Validation integration

The BeanReflector.validate() method:

  1. Creates a temporary bean from the tree snapshot (so cross-value constraints see all current values, not just the one being validated).
  2. Runs jakarta.validation.Validator.validate(bean).
  3. Filters violations to those whose property path matches the requested zhi path.
  4. Maps severity: constraints meta-annotated with @WarnConstraint are reported as Warning (1); all others are Blocking (2).

Cross-value validation

Use a class-level custom constraint that attaches violations to specific property nodes:

@WarnConstraint
@Constraint(validatedBy = SslRequiredForRemoteHostValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface SslRequiredForRemoteHost { /* ... */ }

public class SslRequiredForRemoteHostValidator
        implements ConstraintValidator<SslRequiredForRemoteHost, DatabaseConfig> {
    @Override
    public boolean isValid(DatabaseConfig cfg, ConstraintValidatorContext ctx) {
        if (cfg == null) return true;
        boolean isLocal = "localhost".equals(cfg.getHost());
        if (isLocal || cfg.isSslEnabled()) return true;

        ctx.disableDefaultConstraintViolation();
        String msg = "SSL should be enabled for remote host '" + cfg.getHost() + "'";
        ctx.buildConstraintViolationWithTemplate(msg)
                .addPropertyNode("sslEnabled").addConstraintViolation();
        ctx.buildConstraintViolationWithTemplate(msg)
                .addPropertyNode("host").addConstraintViolation();
        return false;
    }
}

Attaching the violation to both host and sslEnabled ensures the warning appears when either path is validated by the zhi host.

Avoiding the EL dependency

Hibernate Validator normally requires a Jakarta Expression Language implementation for message interpolation. The example avoids this by using ParameterMessageInterpolator, which handles {parameter} placeholders without EL:

Validation.byDefaultProvider()
    .configure()
    .messageInterpolator(new ParameterMessageInterpolator())
    .buildValidatorFactory()
    .getValidator();

This is especially helpful for GraalVM native-image builds where EL adds significant reflection surface.

Building a native image

Gradle configuration

graalvmNative {
    metadataRepository {
        enabled.set(true)   // pull pre-built metadata for gRPC, Netty, etc.
    }
    binaries {
        named("main") {
            imageName.set("zhi-config-javabean")
            mainClass.set("dev.zhi.plugin.javabean.Main")
            buildArgs.addAll("--no-fallback", "-H:+ReportExceptionStackTraces")
        }
    }
}

Reflection & resource configuration

Model classes and annotations accessed via reflection need entries in src/main/resources/META-INF/native-image/<group>/reflect-config.json:

[
  {
    "name": "com.example.model.DatabaseConfig",
    "allDeclaredConstructors": true,
    "allDeclaredMethods": true,
    "allDeclaredFields": true
  },
  {
    "name": "com.example.model.MyValidator",
    "allDeclaredConstructors": true,
    "allDeclaredMethods": true
  }
]

A companion resource-config.json ensures Hibernate Validator’s message bundles and service-loader files are included:

{
  "resources": {
    "includes": [
      {"pattern": "org/hibernate/validator/.*"},
      {"pattern": "META-INF/services/.*"}
    ]
  }
}

Build commands

# JVM jar (for development / fast iteration)
./gradlew build

# Native binary
./gradlew nativeCompile

# The binary is at build/native/nativeCompile/zhi-config-javabean

Tracing agent

If the static configuration is incomplete, use the GraalVM tracing agent to discover additional reflection, resource, and JNI entries:

./gradlew -Pagent run       # run the plugin under the agent
./gradlew metadataCopy      # copy discovered metadata into src/main/resources

Testing

Use gRPC’s in-process transport for unit tests — no network, no subprocess:

@BeforeEach
void setUp() throws Exception {
    String name = InProcessServerBuilder.generateName();

    var reflector = new BeanReflector<>(DatabaseConfig.class, new DatabaseConfig());
    server = InProcessServerBuilder.forName(name)
            .directExecutor()
            .addService(new ConfigServiceImpl(reflector))
            .build().start();

    channel = InProcessChannelBuilder.forName(name)
            .directExecutor().build();
    stub = ConfigServiceGrpc.newBlockingStub(channel);
}

This mirrors the Go testing pattern (goplugin.TestPluginGRPCConn) but uses JUnit 5 and io.grpc:grpc-testing.

Naming and installation

Follow the standard naming convention: zhi-<type>-<name>. Copy the native binary to ~/.zhi/plugins/ for automatic discovery:

cp build/native/nativeCompile/zhi-config-javabean ~/.zhi/plugins/

Checklist for a new Java plugin

  1. Create a Gradle project with protobuf, graalvm-native, and application plugins.
  2. Reference the zhi proto files from api/proto/.
  3. Implement the go-plugin handshake in Main.java (magic cookie, gRPC server, stdout line, stdin watcher).
  4. Implement the gRPC service for your plugin type.
  5. Add GraalVM reflection/resource configs for your model classes.
  6. Write in-process gRPC tests.
  7. Build with ./gradlew nativeCompile and install to ~/.zhi/plugins/.