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 unary RPCs, Flow<T> for streaming. 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/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()
    }
}
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.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"
}
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 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 generated
// 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
    }
}
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.example.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