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.
| 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 |
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
plugins {
java
application
id("com.google.protobuf") version "0.9.4"
id("org.graalvm.buildtools.native") version "0.10.4"
}
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")
}
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.
hashicorp/go-plugin is language-agnostic. A plugin binary must:
ZHI_PLUGIN=zhiplugin-v1). If it is missing or wrong, print a
user-friendly error and exit.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.
// 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 aServerSocket(0), reads its local port, and closes it immediately.
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.
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.
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 (maxConnections → max-connections).
@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.
The BeanReflector.validate() method:
jakarta.validation.Validator.validate(bean).@WarnConstraint are
reported as Warning (1); all others are Blocking (2).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.
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.
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")
}
}
}
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/.*"}
]
}
}
# JVM jar (for development / fast iteration)
./gradlew build
# Native binary
./gradlew nativeCompile
# The binary is at build/native/nativeCompile/zhi-config-javabean
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
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.
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/
protobuf, graalvm-native, and
application plugins.api/proto/.Main.java (magic cookie, gRPC
server, stdout line, stdin watcher)../gradlew nativeCompile and install to ~/.zhi/plugins/.