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."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.Try it on your own .proto.
The converter runs in the browser. Your proto never leaves the page.