Protobuf power tools
Most protobuf workflows compile a schema into code once, ahead of time, and discard the schema itself. ProtoMolt keeps the schema as the working object: the same descriptor can be reflected out of a live server, invoked with no generated stubs, compiled, generated into client code, inferred back from data, merged, and moved between systems.
flowchart LR
git["git repos, cached clones"] --> g["gatherers"]
mvn["Maven coordinates"] --> g
jar["jars, zips, filesystems"] --> g
reg["Confluent-compatible and Apicurio registries"] --> g
g --> ds["one linked descriptor set"]
ds --> a["validate, map, mask on DynamicMessage"]
ds --> b["reflect plus grpc-invoke, no stubs"]
ds --> c["WASM protoc, 9 generator targets"]
ds --> d["infer-schema, synthesize-shape, merge-schemas"]
Everything runs on descriptors, never generated classes
When you write a .proto file, the protobuf toolchain turns it into a
descriptor: a structured object listing every message, field, and type in
the file. Most teams experience descriptors only as a build artifact, an intermediate step on
the way to generated classes. ProtoMolt's foundational rule, stated in its README, is that
everything operates on descriptors (Descriptor / FileDescriptor),
never on those generated classes. The same code path serves a
DynamicMessage (a message instance assembled at runtime from a descriptor,
with no compiled class anywhere) and a compiled-in type, and no module is coupled to any
particular message type.
The platform's own server is the proof. At startup it compiles its service definition with
the runtime compiler (the Square Wire schema library, entirely in memory, no protoc binary)
and binds one dynamic-message handler per method. Server reflection lists it exactly like a
stub-generated service. The tool that manages the format is defined in the format, served
through its own machinery, and discoverable by its own reflect verb.
One resilience detail makes reflected schemas first-class rather than read-only: rules and annotations declared as protobuf custom options are reparsed from unknown fields when a descriptor arrives without the extension registry (over reflection, say, or out of a schema registry). A schema pulled from a live server carries its validation and metadata options with it, and they still take effect.
The compile verb is the same machinery exposed directly: send it
.proto source texts as JSON and it answers with a base64-encoded binary
descriptor set, the canonical compiled form every other tool consumes.
dev-tools/protomolt README and docs/surface/grpc-service.md: the Compile verb compiles sources and returns a descriptor set
$ grpcurl -plaintext -d '{"sources": {"shop/v1/order.proto":
"syntax = \"proto3\";\npackage shop.v1;\nmessage Order { string id = 1; }"}}' \
localhost:9090 ai.pipestream.proto.grpc.service.v1.ProtoMoltService/Compile
{
"ok": true,
"files": ["shop/v1/order.proto"],
"descriptor_set_base64": "CjsKE3Nob3AvdjEvb3JkZXIucHJvdG8SB3Nob3Au..."
} Reflect a live service, then call it, with no stubs
gRPC server reflection is a standard feature: a server can be asked, over
gRPC itself, to describe its own schemas. ProtoMolt's reflect verb points at
any reflection-enabled host and brings back its descriptor set; the grpc-invoke
verb then calls any method named by that descriptor. All four streaming shapes (unary,
server-streaming, client-streaming, bidirectional) work, and no stubs are generated at any
point: the call is assembled from the descriptor at invocation time.
This is exercised, not theoretical. The serve container's end-to-end test drives a dynamic
gRPC call purely by reflection, and the ACP agent (the integration for JetBrains AI chat and
Zed) reflects ProtoMolt's own gRPC service and invokes ListTypes using the
reflected descriptor set. Registering a live endpoint in the service workspace goes one
step further: each unary method becomes a catalog verb named
<profile>-<method>, so ListOrders on the
billing profile answers to billing-list-orders, with no codegen
and no restart. Client-streaming methods get no verb, because a verb takes exactly one
request; the grpc-invoke verb can still call them directly.
flowchart LR
point["point the reflect verb at a host"] --> fd["descriptor set comes back"]
fd --> art["SHA-256 fingerprint pins the artifact"]
art --> inv["grpc-invoke binds a dynamic stub per method"]
inv --> shapes["unary, server, client, and bidi streaming"]
reflect, render-json-schema, generate-stubs):
the address and outputs are illustrative, the verbs and their behavior are the shipped
ones.
- Point
- Inspect
- Generate
Step 1: point at a live gRPC service
dns:///orders.internal:9090 $ grpcurl -plaintext orders.internal:9090 list
shop.v1.CatalogService
shop.v1.OrderService
$ grpcurl -plaintext orders.internal:9090 describe shop.v1.OrderService
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrders(ListOrdersRequest) returns (stream Order);
rpc UploadOrders(stream Order) returns (UploadSummary);
} Step 2: the reflected descriptor set, listed as services and methods
shop.v1.OrderService
rpc GetOrder(GetOrderRequest) returns (Order)
{
"type": "object",
"properties": {
"order_id": { "type": "string" }
},
"required": ["order_id"]
} UploadOrders takes a stream of requests, so the verb catalog does not auto-bind it (a verb takes one request). The grpc-invoke verb can still call it directly, sending a stream of Order messages built from the descriptor.
render-json-schema derives this from the request message's descriptor. The same derivation publishes a catalog verb's inputSchema, so the bounds a caller reads are the bounds the verb enforces.
Step 3: generate-stubs runs protoc's own generators as WebAssembly
# protoc's python and grpc-python generators, running as WebAssembly
import grpc
from shop.v1 import order_pb2 as shop_dot_v1_dot_order__pb2
class OrderServiceStub(object):
"""Client stub for the reflected shop.v1.OrderService."""
def __init__(self, channel):
self.GetOrder = channel.unary_unary(
'/shop.v1.OrderService/GetOrder',
request_serializer=shop_dot_v1_dot_order__pb2
.GetOrderRequest.SerializeToString,
response_deserializer=shop_dot_v1_dot_order__pb2
.Order.FromString,
_registered_method=True)
Canned output for illustration. The bundled WebAssembly module
(protoc-wrapper-v4.wasm, about 2.6 MB) carries all nine plugins:
java, kotlin, grpc-java, python, cpp, csharp, ruby, php, objc.
{
"type": "object",
"properties": {
"customer_id": { "type": "string" },
"page_size": { "type": "integer" }
}
} // protoc's java and grpc-java generators, running as WebAssembly
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = "shop.v1.OrderService/GetOrder",
requestType = shop.v1.GetOrderRequest.class,
responseType = shop.v1.Order.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public abstract void getOrder(
shop.v1.GetOrderRequest request,
io.grpc.stub.StreamObserver<shop.v1.Order> responseObserver); // protoc's csharp generator, running as WebAssembly
public sealed partial class OrderServiceClient
: grpc::ClientBase<OrderServiceClient>
{
public virtual Shop.V1.Order GetOrder(
Shop.V1.GetOrderRequest request,
grpc::Metadata headers = null)
{
return GetOrder(request, new grpc::CallOptions(headers));
}
} # protoc's ruby and grpc-ruby generators, running as WebAssembly
module Shop
module V1
module OrderService
class Service
include GRPC::GenericService
rpc :GetOrder, GetOrderRequest, Order
rpc :ListOrders, ListOrdersRequest, Order
end
Stub = Service.rpc_stub_class
end
end
end
One limit of the derived JSON Schema: it does not model oneof fields and it
omits meta.v1 descriptions.
Generate client code without protoc
Codegen usually means installing the protobuf compiler and its plugins on every machine that
needs it. ProtoMolt instead ships protoc itself compiled to WebAssembly:
one bundled module (protoc-wrapper-v4.wasm, about 2.6 MB, loaded from the
classpath) executed on Chicory, a pure-Java WebAssembly runtime. A
CodeGeneratorRequest goes in on stdin and a
CodeGeneratorResponse comes back on stdout, exactly the protoc plugin
protocol, with no native toolchain anywhere. The module and approach come from
protobuf4j (Apache-2.0), which compiles upstream protobuf to Wasm; the embedded binary's
SHA-256 is verified against a recorded provenance file.
On a regular JVM the module is compiled to JVM bytecode once per process, so repeated invocations are fast. Inside the GraalVM native CLI the same module runs on Chicory's interpreter: same generators, slower per-invocation execution, still no native compiler.
The whole thing is exposed as the generate-stubs verb (scope
schema-read), which puts it on every front: gRPC, REST, MCP, ACP, and the CLI.
An agent holding only a reflected service's descriptor set can generate a compilable client
for it without protoc installed.
Those nine are the targets the bundled WASM module emits. Because every contract is
standard protobuf, gRPC clients work in all 13
languages gRPC supports; the WASM generator covers the cases where you want stubs
produced for you, and any other language compiles the same .proto with its own
toolchain.
Infer a schema from sample JSON
Not every data source has a schema. The infer-schema verb reverse-engineers a
proto definition from data-rich JSON, per its own description: objects become nested
messages, arrays become repeated fields with element inference, and JSON numbers become
int64 when they are integral across every sample and
double otherwise. Anything genuinely dynamic (mixed-type values, empty
objects, empty or mixed arrays, null-only keys) falls back to
google.protobuf.Value rather than guessing. Keys are sanitized to legal field
identifiers, and when sanitization changes a key the field carries json_name
with the original, so the inferred schema round-trips the very documents it was inferred
from. Feeding more samples improves the result: keys union, and the numeric heuristic sees
every occurrence. A depth cap of 32 guards against adversarial nesting.
The output is proto source plus a linked descriptor set, like every other shape verb, so an inferred schema is immediately usable by validation, mapping, and the rest of the toolkit. It is one pipe away on the native CLI:
dev-tools/protomolt docs and apps/cli: infer-schema over stdin, no server needed
echo '{"samples": [{"name": "x", "n": 1}]}' \
| docker run --rm -i ghcr.io/ai-pipestream/protomolt-cli infer-schema Synthesize shapes and merge schemas
The synthesize-shape verb derives a new message type from existing ones in
three shapes: an envelope (one message field per named source, lossless),
a projection (a flat message whose field types come from scoped source
paths, so the SELECT list becomes the schema), and a tagged union (a
protobuf oneof over the source types).
The important part is what the output is: the shape is built as a
FileDescriptorProto that depends on the sources' files, linked in-process, and
emitted as .proto source with true import paths. A synthesized shape is a real,
linked protobuf type, not a runtime convenience object. It registers in the git-backed
schema registry with references, history, diffs, and compatibility gates, exactly like a
hand-written schema, and when the join definition changes, check-compat says
whether downstream consumers survive.
merge-schemas combines whole schemas in three steps. Validate:
a field with the same name and type in two sources is a natural join key and is
coalesced; the same name with a different type or cardinality is a hard clash that
blocks emission. Resolve: each clash is settled by renaming (defaulting to
<source>_<field>), preferring one source, or overriding the
coalescing. Emit: proto source, a descriptor set, and both relationships
made explicit: the defined join (one ruleset reading all sources at once) and the
defined union (one ruleset per source, a structural UNION). A
report-only mode runs the clash report standalone before you commit to a merge. Map-typed
fields are refused for now, with a clear error rather than a silent guess.
Gather .proto sources from anywhere
Schemas live in many places, so acquisition goes through one small interface. A
gatherer returns a ProtoSourceSet (source texts keyed by
import path) and names its origin; a composite merges several, refusing identical import
paths that hold different content. Gathered sources compile in memory through the shared
compiler, so consumers get descriptors, never files on disk.
dev-tools/protomolt acquire/gather, ProtoGatherer.java: the whole acquisition contract
public interface ProtoGatherer {
ProtoSourceSet gather() throws GatherException;
String origin(); // e.g. git:<repo>@<ref>, jar:<file>, maven:<coordinate>
default boolean isAvailable() { return true; }
} | Gatherer | Pulls from |
|---|---|
FilesystemProtoGatherer | Explicit import roots, or a scan root that discovers nested
src/main/proto trees, skipping hidden and build directories. |
JarProtoGatherer | .proto entries inside jars and zips; the in-jar path becomes the import
path; well-known types are skipped because the compiler supplies them. |
GitProtoGatherer | Git repos through JGit with a persistent clone cache; branch, tag, or commit SHA;
token or username/password auth; multi-module, explicit-path, or single-subdir
layouts. Ships the gather-git verb. |
MavenProtoGatherer | Maven coordinates via the standalone Maven Resolver, no Maven installation; an optional transitive mode walks the runtime graph and scans every resolved jar. |
CompositeProtoGatherer | Any ordered combination of the above. |
Two builders show the shape of it. The git gatherer caches clones under
~/.cache/protomolt/gather/git (overridable), can run offline against a warm
cache, and exists for the common case of services that publish their contract in git
rather than enabling reflection. The Maven gatherer resolves
group:artifact:version[:classifier] against configurable remotes through the
local ~/.m2 repository. The Maven gatherer is a library with no verb of its
own; the git gatherer ships as the gather-git verb.
dev-tools/protomolt docs/acquire/gathering.md: the git and Maven gatherers
Show the actual definition
var gatherer = GitProtoGatherer.builder()
.repo("https://github.com/example/schemas.git")
.ref("main") // branch, tag, or commit SHA
.subdir("proto") // default
.token(System.getenv("GH_TOKEN")) // or username/password
.build();
var gatherer = MavenProtoGatherer.builder()
.coordinate("com.example:common-protos:1.4.0") // g:a:v, optional :classifier
.repositories(List.of("https://repo1.maven.org/maven2/"))
.transitive(false) // true scans the runtime graph
.build(); On the registry side, loaders and publishers speak the protocols that already exist. The Confluent-compatible loader works against Confluent itself, Apicurio's compatibility facade, Redpanda, or ProtoMolt's own git-backed registry: it lists subjects, fetches references recursively with cycle detection, and compiles each schema in memory. One bad subject (dangling references, unparseable text) is skipped with a warning so it never poisons the whole load, while registry-level failures abort loudly. Lookups cache for 30 seconds; schema-by-id caches forever, because an id in a Confluent-compatible registry names one exact schema and never names another. The matching publisher registers files in reverse-topological import order so every reference exists before the file that imports it, and reports a per-file outcome: created, updated, unchanged, would-write (dry run), or failed. An Apicurio Registry v3 loader and publisher ship natively as a Quarkus extension that can auto-load descriptors on startup. The publisher supports an API token; the loaders speak anonymous HTTP.
Gather and publish compose into one bridge: pull sources from wherever they live, push them into a registry with compatibility gates in between.
dev-tools/protomolt docs/schema/publishing.md: gather, then publish into a Confluent-compatible registry
Show the actual definition
var sources = gatherer.gather();
try (var publisher = new ConfluentSchemaPublisher(URI.create("http://localhost:8081"))) {
PublishResult result = publisher.publish(sources, PublishOptions.defaults());
result.throwIfFailed();
} Moving descriptor sets between peers
Descriptors are also the interchange format between systems. A registered service profile pins the SHA-256 fingerprint of its descriptor set, so reflection and registration send the descriptor once and later inspection and invocation resolve it inside ProtoMolt. The schema registry serves a subject's latest schema as a binary descriptor set, topologically ordered with dependencies first, so a consumer can link the whole set in one forward pass.
For peer-to-peer sync there is a designed wire contract,
DescriptorExchangeService: content-addressed descriptor-set transfer where a
set's identity is the SHA-256 of its canonical bytes, so registering the same content twice
is idempotent. It defines register, get, paged list, and a bidirectional sync stream so two
peers can converge on the same inventory without a shared store. It is a designed contract,
not yet a running server; today's real sync paths are git federation between registries and
the HTTP descriptor-set route.