Protobuf to
Kotlin.
Turn a .proto file into a Kotlin Ktor client. oneof becomes a sealed class so when branches are exhaustive. kotlinx.serialization for JSON, suspend fun for unary RPCs, Flow<T> for streaming. One file per message. No signup, no install.
syntax = "proto3";
package payments.v1;
service PaymentsService {
rpc GetPayment(GetPaymentRequest) returns (Payment);
rpc CreatePayment(CreatePaymentRequest) returns (Payment);
}
message Payment {
string id = 1;
string customer_id = 2;
int64 amount = 3;
Currency currency = 4;
PaymentStatus status = 5;
optional string description = 6;
oneof method {
CardPayment card = 10;
BankPayment bank = 11;
WalletPayment wallet = 12;
}
}
enum Currency {
CURRENCY_UNSPECIFIED = 0;
CURRENCY_EUR = 1;
CURRENCY_USD = 2;
}// com/example/payments/client/PaymentsService.kt
package com.example.payments.client
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import com.example.payments.model.GetPaymentRequest
import com.example.payments.model.Payment
import com.example.payments.model.CreatePaymentRequest
class PaymentsService(httpClient: HttpClient, baseUrl: String) : BaseHttpClient(httpClient, baseUrl) {
suspend fun getPayment(requestBody: GetPaymentRequest): Payment {
return connectPost("/payments.v1.PaymentsService/GetPayment", requestBody).body()
}
suspend fun createPayment(requestBody: CreatePaymentRequest): Payment {
return connectPost("/payments.v1.PaymentsService/CreatePayment", requestBody).body()
}
}Idiomatic Kotlin. Not a protoc escape hatch.
Below: types emitted from a small payments .proto with a oneof and an optional field. Each message lands in its own file; enums are real Kotlin enums; the oneof resolves to a sealed hierarchy you can exhaustively when over.
// model/Payment.kt
package com.example.payments.model
import kotlinx.serialization.Serializable
@Serializable
data class Payment(
val id: String,
val customerId: String,
val amount: Long,
val currency: Currency,
val status: PaymentStatus,
val description: String? = null,
val method: MethodUnion? = null
)
// model/Currency.kt
@Serializable
enum class Currency {
CURRENCY_UNSPECIFIED,
CURRENCY_EUR,
CURRENCY_USD
}
// Exhaustive branching — no else, no cast:
fun label(p: Payment): String = when (val m = p.method) {
is MethodUnion.MethodUnionCard -> "card ${m.value.last4}"
is MethodUnion.MethodUnionBank -> "bank ${m.value.iban}"
is MethodUnion.MethodUnionWallet -> "wallet ${m.value.provider}"
null -> "no method"
}Grounded in how the proto reads.
Each claim is something you can verify by running the converter on your own .proto. Click a card to see the proto fragment that triggered it and the Kotlin that came out.
message Payment {
// ...
oneof method {
CardPayment card = 10;
BankPayment bank = 11;
WalletPayment wallet = 12;
}
}// model/MethodUnion.kt
@Serializable(with = MethodUnionSerializer::class)
sealed class MethodUnion {
@Serializable
data class MethodUnionCard(val value: CardPayment) : MethodUnion()
@Serializable
data class MethodUnionBank(val value: BankPayment) : MethodUnion()
@Serializable
data class MethodUnionWallet(val value: WalletPayment) : MethodUnion()
}
// A custom KSerializer is emitted alongside, so
// {"card": {...}} deserializes into MethodUnionCard
// without a discriminator field on the wire.A suspending client, not a stub.
The service extends a thin BaseHttpClient that owns the Ktor HttpClient and the base URL. Each unary RPC is a suspend fun that POSTs to /package.Service/Method with a Connect-Protocol-Version header. Non-success responses route through decodeConnectError so the Connect JSON error envelope becomes a typed GrpcException — not an opaque body() decode failure. Streaming RPCs return Flow<T> from the same class.
// com/example/payments/client/PaymentsService.kt
package com.example.payments.client
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import com.example.payments.model.GetPaymentRequest
import com.example.payments.model.Payment
import com.example.payments.model.CreatePaymentRequest
class PaymentsService(httpClient: HttpClient, baseUrl: String) : BaseHttpClient(httpClient, baseUrl) {
suspend fun getPayment(requestBody: GetPaymentRequest): Payment {
return connectPost("/payments.v1.PaymentsService/GetPayment", requestBody).body()
}
suspend fun createPayment(requestBody: CreatePaymentRequest): Payment {
return connectPost("/payments.v1.PaymentsService/CreatePayment", requestBody).body()
}
}
// BaseHttpClient.kt (shared)
abstract class BaseHttpClient(private val _httpClient: HttpClient, protected val baseUrl: String) {
protected val httpClient: HttpClient
init {
httpClient = _httpClient
}
class GrpcException(val code: String, message: String?) : RuntimeException(message)
protected suspend fun decodeConnectError(response: HttpResponse): GrpcException? {
if (response.status.isSuccess()) return null
// Parse the Connect JSON error envelope { code, message } into a typed exception
// so .body() never tries to decode an error payload as the success type.
// ... (envelope-decoding implementation)
return GrpcException("unknown", "HTTP ${response.status.value}")
}
protected suspend fun connectPost(path: String, body: Any?): HttpResponse {
val response = httpClient.post("$baseUrl$path") {
contentType(HttpContentType.Application.Json)
header("Connect-Protocol-Version", "1")
accept(HttpContentType.Application.Json)
setBody(body ?: "{}")
}
decodeConnectError(response)?.let { throw it }
return response
}
}Or ask Claude to do it.
The same generator ships as a Model Context Protocol server. It calls the same web API this converter uses, so the output is identical. Point Claude Desktop, Cursor, or any MCP-aware agent at a .proto and it produces the Kotlin client as a diff — free, no token, no leaving the chat.
// claude-desktop config · .mcp.json
{
"mcpServers": {
"metaengine": {
"command": "npx",
"args": ["-y", "@metaengine/mcp-server"]
}
}
}
// Then in Claude:
// "Load proto/payments.proto and generate a Kotlin
// Ktor client in package com.example.payments."Same generator, as a package.
The converter above is the fastest way to try the output. For CI and repeatable builds, the identical generator ships as a package — pin a version, wire it into your pipeline, and review diffs like any other code.
Things people ask.
The questions we keep getting about Protobuf → Kotlin. If yours is not here, run the converter — the answer is usually in the output.
.kt files: one data class per message, one enum class per enum, a sealed class per oneof, and one Ktor client class per service. Plus a shared BaseHttpClient. No protoc plugin, no gradle task, no generated-sources folder.Same engine, other stacks.
Every converter runs the same spec-to-IR pipeline. Pick another source format or target stack.
Try it on your own .proto.
The converter UI runs in your browser. When you generate, your specification (or its URL), selected options, and any custom content you explicitly include are sent over HTTPS to the MetaEngine API. The API processes the payload ephemerally; specifications and generated code are not persisted or logged.
Read the public privacy policy