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 every RPC. 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/metaengine/payments/client/PaymentsService.kt
package com.metaengine.payments.client
import io.ktor.client.*
import io.ktor.client.call.*
import com.metaengine.payments.model.GetPaymentRequest
import com.metaengine.payments.model.Payment
import com.metaengine.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.metaengine.payments.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
@Serializable
data class Payment(
val id: String,
@SerialName("customer_id")
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 RPC is a suspend fun that POSTs to /package.Service/Method with a Connect-Protocol-Version header. JSON serialization is kotlinx.serialization.
// com/metaengine/payments/client/PaymentsService.kt
package com.metaengine.payments.client
import io.ktor.client.*
import io.ktor.client.call.*
import com.metaengine.payments.model.GetPaymentRequest
import com.metaengine.payments.model.Payment
import com.metaengine.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 = _httpClient.config { }
protected suspend fun connectPost(path: String, body: Any?): HttpResponse {
return httpClient.post("$baseUrl$path") {
contentType(ContentType.Application.Json)
header("Connect-Protocol-Version", "1")
accept(ContentType.Application.Json)
setBody(body ?: "{}")
}
}
}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.acme.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.