Ktor Server skill for Codex

Kotlin APIs with plugins that tests can swap. Codex structures Ktor services as small Application modules that take their dependencies as parameters, so testApplication can install exactly what a test needs. Bodies are serializable data classes, StatusPages turns validation, bad input and missing resources into one JSON error shape, JWT routes are wrapped in authenticate, and JDBC work moves off the event loop onto Dispatchers.IO.

  • Backend & APIs
  • $ktor-server
  • 222 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- ktor-server

Installs to ~/.agents/skills/ktor-server/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $ktor-server, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds Kotlin HTTP services with Ktor 3 server - Application modules, routing and type-safe Resources, ContentNegotiation with kotlinx.serialization, StatusPages error mapping, RequestValidation, JWT Authentication, CallLogging with CallId, coroutine-safe database access on Dispatchers.IO, YAML or HOCON configuration, and testApplication tests. Use when adding routes or plugins to a Ktor server, fixing serialization or 415/400 errors, structuring modules for testability, wiring JWT auth, or building a fat JAR or Docker image.

Use it when

  • Adding routes, plugins or authentication to a Ktor 3 server
  • Serialization, content negotiation or receive errors appear in a Kotlin API
  • Ktor modules are hard to test because dependencies are global
  • Database or file I/O runs directly inside Ktor route handlers

Use something else when

  • The service is Spring Boot written in Kotlin: use spring-boot
  • The question is coroutine scopes, Flow or cancellation in general: use kotlin-coroutines

How to check the result

./gradlew build passes with testApplication tests that cover success, 401 without a token, 404, and a validation failure returning the shared error JSON.

The mistake it prevents

Missing the kotlinx serialization Gradle plugin or ContentNegotiation install, which compiles cleanly and then fails at runtime with 415 or serializer-not-found errors.

~/.agents/skills/ktor-server/SKILL.md

--- name: ktor-server description: Builds Kotlin HTTP services with Ktor 3 server - Application modules, routing and type-safe Resources, ContentNegotiation with kotlinx.serialization,… license: MIT ---

Done well means: the app is split into extension-function modules that tests can install individually, request and response bodies are @Serializable data classes, every error path goes through StatusPages to one JSON shape, blocking I/O never runs on the event loop, and each route has a testApplication test.

Before changing anything

  1. Read AGENTS.md, build.gradle.kts (Ktor version, engine artifact such as ktor-server-netty or ktor-server-cio, serialization plugin) and the main entry.
  2. Find the config source: application.yaml / application.conf with EngineMain, or code-only embeddedServer(...). Keep whichever the project uses.
  3. Baseline: ./gradlew build. Gradle downloads need network; offline, rely on the local Gradle cache or ask before enabling network.

Structure

fun main(args: Array<String>) = io.ktor.server.netty.EngineMain.main(args)

// application.yaml: ktor.application.modules lists these
fun Application.module() {
    configureSerialization()
    configureMonitoring()
    configureErrors()
    configureSecurity()
    configureRouting(OrderService(Database.connect(environment.config)))
}
ktor:
  deployment:
    port: "$PORT:8080"
  application:
    modules:
      - com.acme.ApplicationKt.module
jwt:
  issuer: "https://auth.acme.dev/"
  audience: "orders-api"
  secret: "$JWT_SECRET"

"$ENV:default" reads an environment variable with a fallback. Read values with environment.config.property("jwt.issuer").getString(). Never commit secrets in config.

Pass dependencies into routing functions (constructor-style) or use a DI container (Koin, or the Ktor DI plugin in 3.2+). Avoid top-level singletons that tests cannot replace.

Plugins

fun Application.configureSerialization() {
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true; explicitNulls = false })
    }
}

fun Application.configureMonitoring() {
    install(CallId) { retrieveFromHeader(HttpHeaders.XRequestId); generate { UUID.randomUUID().toString() } }
    install(CallLogging) { callIdMdc("call-id"); filter { it.request.path().startsWith("/api") } }
}

fun Application.configureErrors() {
    install(StatusPages) {
        exception<RequestValidationException> { call, e ->
            call.respond(HttpStatusCode.UnprocessableEntity, ApiError("validation_failed", e.reasons))
        }
        exception<BadRequestException> { call, e ->
            call.respond(HttpStatusCode.BadRequest, ApiError("bad_request", listOfNotNull(e.message)))
        }
        exception<NotFoundException> { call, _ ->
            call.respond(HttpStatusCode.NotFound, ApiError("not_found"))
        }
        exception<Throwable> { call, e ->
            call.application.log.error("Unhandled", e)
            call.respond(HttpStatusCode.InternalServerError, ApiError("internal"))
        }
    }
    install(RequestValidation) {
        validate<CreateOrder> { o ->
            if (o.quantity !in 1..100) ValidationResult.Invalid("quantity must be 1..100")
            else ValidationResult.Valid
        }
    }
}

@Serializable
data class ApiError(val code: String, val details: List<String> = emptyList())
PluginWhy
ContentNegotiation + json()Without it call.receive<T>() fails with 415 and respond(obj) fails
StatusPagesSingle error shape; keeps try/catch out of routes
RequestValidationDeclarative checks on received bodies; throws RequestValidationException
CallId + CallLoggingRequest IDs in MDC for every log line
AuthenticationJWT, sessions, OAuth; wrap protected routes in authenticate {}
CORS, DefaultHeaders, CompressionOnly when needed; CORS allow-list explicit hosts

Routing

fun Application.configureRouting(orders: OrderService) {
    routing {
        get("/healthz") { call.respond(HttpStatusCode.NoContent) }
        route("/api/orders") {
            authenticate("auth-jwt") {
                get("/{id}") {
                    val id = call.parameters.getOrFail<Long>("id")   // 400 if missing or not a Long
                    val order = orders.find(id) ?: throw NotFoundException()
                    call.respond(order)
                }
                post {
                    val userId = call.principal<JWTPrincipal>()!!.subject!!
                    val req = call.receive<CreateOrder>()
                    call.respond(HttpStatusCode.Created, orders.create(userId, req))
                }
            }
        }
    }
}
  • call.receive reads the body once. A second receive fails unless the DoubleReceive plugin is installed; receive once and pass the object along.
  • For many routes with typed parameters, the Resources plugin (@Resource classes and get<Orders.Id> { res -> }) gives compile-checked paths and URL building.

JWT authentication

fun Application.configureSecurity() {
    val cfg = environment.config
    val issuer = cfg.property("jwt.issuer").getString()
    val audience = cfg.property("jwt.audience").getString()
    install(Authentication) {
        jwt("auth-jwt") {
            realm = "orders-api"
            verifier(
                JWT.require(Algorithm.HMAC256(cfg.property("jwt.secret").getString()))
                    .withIssuer(issuer).withAudience(audience).build()
            )
            validate { cred -> if (cred.payload.subject != null) JWTPrincipal(cred.payload) else null }
            challenge { _, _ -> call.respond(HttpStatusCode.Unauthorized, ApiError("unauthorized")) }
        }
    }
}

For tokens from an identity provider, verify with its JWKS (jwt { verifier(jwkProvider, issuer) }) rather than a shared secret.

Coroutines and blocking work

Ktor handlers run on a small event-loop pool. JDBC, file I/O and CPU-heavy work must leave it:

suspend fun <T> dbQuery(block: () -> T): T = withContext(Dispatchers.IO) { transaction { block() } }

With Exposed, newSuspendedTransaction(Dispatchers.IO) { ... } does the same. Use a coroutine-native client (Ktor client, R2DBC, the Lettuce coroutine API) where one exists. Never call runBlocking inside a route.

Testing

class OrderRoutesTest {
    @Test
    fun `unknown order returns 404`() = testApplication {
        environment { config = MapApplicationConfig("jwt.issuer" to "t", "jwt.audience" to "t", "jwt.secret" to "s") }
        application {
            configureSerialization(); configureErrors(); configureSecurity()
            configureRouting(FakeOrderService())
        }
        val client = createClient { install(ClientContentNegotiation) { json() } }
        val res = client.get("/api/orders/999") { bearerAuth(testToken("s")) }
        assertEquals(HttpStatusCode.NotFound, res.status)
    }
}

Import the client plugin with an alias (io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation) to avoid clashing with the server plugin. Installing modules piecemeal is why modules should take their dependencies as parameters.

Build and run

./gradlew test
./gradlew buildFatJar          # io.ktor.plugin; output in build/libs/*-all.jar
./gradlew run                  # local dev

The Ktor Gradle plugin also offers buildImage / publishImageToLocalRegistry through Jib; publishing an image to a remote registry is outward-facing, so confirm with the user.

Checklist

  • Modules are extension functions taking dependencies as parameters
  • Bodies are @Serializable; ContentNegotiation configured once
  • StatusPages maps validation, bad request, not found, and fallback 500
  • Protected routes inside authenticate; config secrets from environment
  • Blocking calls on Dispatchers.IO
  • ./gradlew build green with a testApplication test per route

Gotchas

  • Forgetting the kotlin("plugin.serialization") Gradle plugin compiles fine but fails at runtime with “Serializer for class … is not found”.
  • call.respond(someMap) with mixed value types cannot be serialized; use a data class.
  • Route order matters only within the same specificity; a literal segment beats a parameter.
  • Upgrading from Ktor 2: package and API changes (for example engine and config APIs, kotlinx.io based I/O); follow the official migration guide rather than guessing.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Ktor Server"
  short_description: "Kotlin APIs with plugins that tests can swap"
  default_prompt: "$ktor-server Add authenticated order endpoints to our Ktor service with JSON validation"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem