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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ktor-server Installs to ~/.agents/skills/ktor-server/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ktor-server The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ktor-server Installs to .agents/skills/ktor-server/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ktor-server.zipunzip -o ktor-server.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ktor-server PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
- Read
AGENTS.md,build.gradle.kts(Ktor version, engine artifact such asktor-server-nettyorktor-server-cio, serialization plugin) and themainentry. - Find the config source:
application.yaml/application.confwithEngineMain, or code-onlyembeddedServer(...). Keep whichever the project uses. - 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())
| Plugin | Why |
|---|---|
ContentNegotiation + json() | Without it call.receive<T>() fails with 415 and respond(obj) fails |
StatusPages | Single error shape; keeps try/catch out of routes |
RequestValidation | Declarative checks on received bodies; throws RequestValidationException |
CallId + CallLogging | Request IDs in MDC for every log line |
Authentication | JWT, sessions, OAuth; wrap protected routes in authenticate {} |
CORS, DefaultHeaders, Compression | Only 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.receivereads the body once. A second receive fails unless theDoubleReceiveplugin is installed; receive once and pass the object along.- For many routes with typed parameters, the
Resourcesplugin (@Resourceclasses andget<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 buildgreen with atestApplicationtest 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.iobased 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.yaml
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.