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
Prefer it in your build?

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.

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.
More converters

Same engine, other stacks.

Every converter runs the same spec-to-IR pipeline. Pick another source format or target stack.

OpenAPITypeScript
Angular httpResource, React TanStack Query, or framework-agnostic fetch.
Open converter
OpenAPIAngular
Typed services with httpResource, Signals, inject() DI, and interceptors.
Open converter
OpenAPIReact
TanStack Query hooks with stable query keys, or plain async functions.
Open converter
OpenAPIFetch
Framework-agnostic fetch client with ApiResult, middleware, and retries.
Open converter
OpenAPIJava Spring
RestClient services, Java records, sealed interfaces, Spring Boot 3.
Open converter
OpenAPIPython
Async httpx clients paired with Pydantic v2 models.
Open converter
OpenAPIGo
Idiomatic net/http client, context-aware, pointer-nullable fields.
Open converter
OpenAPIKotlin
Ktor client, kotlinx.serialization, sealed interfaces for oneOf.
Open converter
OpenAPIC#
Records, nullable reference types, JsonPolymorphic, HttpClient.
Open converter
OpenAPIRust
reqwest client with serde-tagged enums for oneOf and Option<T>.
Open converter
GraphQLAngular
Observable services with typed queries, mutations, and graphql-ws subscriptions.
Open converter
GraphQLReact
TanStack Query hooks with discriminated-union types and query keys.
Open converter
GraphQLKotlin
Ktor, kotlinx.serialization, Flow-based subscriptions.
Open converter
GraphQLC#
Records, nullable types, [JsonPolymorphic] unions, IAsyncEnumerable subscriptions.
Open converter
ProtobufC#
Records, nullable types, Connect RPC over HttpClient.
Open converter
ProtobufGo
Plain structs with json tags, pointer presence, stdlib-only Connect client.
Open converter
SQLTypeScript
Postgres DDL to typed interfaces with foreign-key navigation.
Open converter
SQLKotlin
CREATE TABLE scripts to Kotlin @Serializable data classes.
Open converter

Try it on your own .proto.

The converter runs in the browser. Your proto never leaves the page.

Open the converter