Skip to content

Getting started

Host (Ktor)

Stand up a Keel host, registry, HTML shell, visits, and /__keel/schema.

This walkthrough is the server half. You will have a Ktor process that owns URLs, page ids, and payload types, serves an HTML shell, answers visits as JSON, and exposes the pack contract at GET /__keel/schema.

The pack (framework pages, Vite, .feb) is the next page: Pack.

1. Gradle project

Java 17+, Kotlin 2.2, Ktor 3. Add the releases repo and dev.kolektiv.keel:core from Server Installation. This walkthrough also needs the Ktor adapter and a server engine.

plugins + ktor adapter
plugins {
  kotlin("jvm") version "2.2.21"
  kotlin("plugin.serialization") version "2.2.21"
  application
}

repositories {
  mavenCentral()
  maven("https://repo.yuri.capital/repository/maven-releases/")
}

dependencies {
  implementation("dev.kolektiv.keel:ktor:0.1.0")
  implementation("io.ktor:ktor-server-netty:3.1.3")
}

application {
  mainClass.set("com.example.app.MainKt")
}
plugins {
  id 'org.jetbrains.kotlin.jvm' version '2.2.21'
  id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21'
  id 'application'
}

repositories {
  mavenCentral()
  maven { url 'https://repo.yuri.capital/repository/maven-releases/' }
}

dependencies {
  implementation 'dev.kolektiv.keel:ktor:0.1.0'
  implementation 'io.ktor:ktor-server-netty:3.1.3'
}

application {
  mainClass = 'com.example.app.MainKt'
}

2. Pages and a pack

A page is an id, a path, and a @Serializable payload. The pack file is a .feb (zip of manifest.json + modules). Until you build one, point at an exploded pack/dist from Vite or a file you already have.

App.kt
@Serializable
data class HomePage(val greeting: String)

@Serializable
data class NotFoundPage(val path: String)

fun Application.app(bundle: FrontendBundle) {
  keel {
      this.bundle = bundle
      title = "App"
      notFoundPageId = "app.notFound"
      pages {
          page<HomePage>("app.home", "/") {
              head("Home", description = "A Keel host.")
              HomePage("hello")
          }
          page<NotFoundPage>("app.notFound", "/__not-found") {
              head("Not found")
              NotFoundPage(path)
          }
      }
  }
}

keel { } installs routes: document GET, visits on the page URL, POST /__keel/action/{id}, pack files under /__keel/pack/{id}/…, and GET /__keel/schema.

3. Boot Netty

Main.kt
fun main() {
  val bundle = FrontendBundle.fromFile(Path.of("pack/dist/app.feb"))
  embeddedServer(Netty, port = 8090, host = "127.0.0.1") {
      app(bundle)
  }.start(wait = true)
}

FrontendBundle.fromResource("keel/app.feb") is the usual production shape (the .feb on the classpath). fromDirectory(Path.of("pack/dist")) is handy while Vite --watch is rewriting dist/.

4. Run and probe

./gradlew run
curl -sS http://127.0.0.1:8090/ | head
curl -sS http://127.0.0.1:8090/ -H 'X-Keel-Visit: true'
curl -sS http://127.0.0.1:8090/__keel/schema

The document is the HTML shell (#__keel_seed, modulepreload, styles with data-keel-css). The visit is the same seed as JSON. The schema is the keel/1 contract keel-scaffold fetches.

What you did not do

You did not give the pack a URL router. Paths stay on this process. After the pack exists, adding a second theme is a second FrontendBundle passed at render time, same page<T>(id, path) calls.