Protobuf·Kotlin·Free

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.

proto3·Kotlin·Ktor·Coroutines·MIT
payments.protoInput
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;
}
compile
PaymentsService.kt tsc cleanOutput
// 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()
    }
}
What gets generated

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"
}
Three things we get right

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.

01ProofSealed class for every oneof.Spec · InCode · Out
payments.proto fragment
message Payment {
  // ...
  oneof method {
    CardPayment card = 10;
    BankPayment bank = 11;
    WalletPayment wallet = 12;
  }
}
MethodUnion.kt
// 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.
Kotlin · Ktor

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 generated
// 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 ?: "{}")
        }
    }
}
Also available via MCP

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."
See the MCP page
Questions

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.

A package of .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.

Open the converter