Actions are typed RPC. They are not pages. They are not a second read model.
The host writes a Kotlin function: one @Serializable input, one
@Serializable output. Ktor binds POST /__keel/action/{id}.
@KeelType
@Serializable
data class SetNameIn(val displayName: String)
@KeelType
@Serializable
data class SetNameOut(val user: UserRef)
object Board {
@KeelAction("harbor.setName")
fun ApplicationCall.setName(input: SetNameIn): SetNameOut {
val trimmed = input.displayName.trim()
if (trimmed.length < 2) {
throw PageValidationException(
mapOf("displayName" to listOf("Display name must be 2 to 40 characters.")),
)
}
// ...
return SetNameOut(user.toRef())
}
}
keel {
actions(Board)
}Make the function an extension on ApplicationCall when it needs cookies or
session; this is the current request. Validation throws
PageValidationException and becomes 422 { "errors": { … } }. Success
is 200 { "data": … }. CSRF and status codes are on
the wire protocol.
The pack calls the id — never a URL it invented:
import { action } from "@kolektiv/keel"
const out = await action<SetNameIn, SetNameOut>("harbor.setName", {
displayName: "Ada",
})import { action } from "@kolektiv/keel"
const out = await action("harbor.setName", {
displayName: "Ada",
})useAction(id) wraps that in a mutation that reloads the current seed:
<script lang="ts">
import { ActionError, page, useAction } from "@kolektiv/keel-svelte"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"
const ctx = page<HomePage>()
const setName = useAction<SetNameIn, SetNameOut>("harbor.setName")
let displayName = $state(ctx.data.viewer?.displayName ?? "")
function onSetName(event: SubmitEvent) {
event.preventDefault()
void setName.mutateAsync({ displayName }).catch(() => undefined)
}
</script>
<form onsubmit={onSetName}>
<input bind:value={displayName} />
{#if setName.error instanceof ActionError}
<p>{setName.error.errors.displayName?.join(" ")}</p>
{/if}
<button type="submit" disabled={setName.isPending}>Save</button>
</form><script>
import { ActionError, page, useAction } from "@kolektiv/keel-svelte"
const ctx = page()
const setName = useAction("harbor.setName")
let displayName = $state(ctx.data.viewer?.displayName ?? "")
function onSetName(event) {
event.preventDefault()
void setName.mutateAsync({ displayName }).catch(() => undefined)
}
</script>
<form onsubmit={onSetName}>
<input bind:value={displayName} />
{#if setName.error instanceof ActionError}
<p>{setName.error.errors.displayName?.join(" ")}</p>
{/if}
<button type="submit" disabled={setName.isPending}>Save</button>
</form>import { useState, type FormEvent } from "react"
import { ActionError, useAction, usePage } from "@kolektiv/keel-react"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"
export default function SetName() {
const seed = usePage<HomePage>()
const setName = useAction<SetNameIn, SetNameOut>("harbor.setName")
const [displayName, setDisplayName] = useState(seed.data.viewer?.displayName ?? "")
function onSetName(event: FormEvent) {
event.preventDefault()
void setName.mutateAsync({ displayName }).catch(() => undefined)
}
return (
<form onSubmit={onSetName}>
<input value={displayName} onInput={(event) => setDisplayName(event.currentTarget.value)} />
{setName.error instanceof ActionError && (
<p>{setName.error.errors.displayName?.join(" ")}</p>
)}
<button type="submit" disabled={setName.isPending}>Save</button>
</form>
)
}import { useState } from "react"
import { ActionError, useAction, usePage } from "@kolektiv/keel-react"
export default function SetName() {
const seed = usePage()
const setName = useAction("harbor.setName")
const [displayName, setDisplayName] = useState(seed.data.viewer?.displayName ?? "")
function onSetName(event) {
event.preventDefault()
void setName.mutateAsync({ displayName }).catch(() => undefined)
}
return (
<form onSubmit={onSetName}>
<input value={displayName} onInput={(event) => setDisplayName(event.currentTarget.value)} />
{setName.error instanceof ActionError && (
<p>{setName.error.errors.displayName?.join(" ")}</p>
)}
<button type="submit" disabled={setName.isPending}>Save</button>
</form>
)
}<script setup lang="ts">
import { computed, ref } from "vue"
import { ActionError, useAction, usePage } from "@kolektiv/keel-vue"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"
const seed = usePage<HomePage>()
const setName = useAction<SetNameIn, SetNameOut>("harbor.setName")
const displayName = ref(seed.value.data.viewer?.displayName ?? "")
const errorText = computed(() => {
const error = setName.error.value
return error instanceof ActionError ? error.errors.displayName?.join(" ") : undefined
})
function onSetName() {
void setName.mutateAsync({ displayName: displayName.value }).catch(() => undefined)
}
</script>
<template>
<form @submit.prevent="onSetName">
<input v-model="displayName" />
<p v-if="errorText">{{ errorText }}</p>
<button type="submit" :disabled="setName.isPending">Save</button>
</form>
</template><script setup>
import { computed, ref } from "vue"
import { ActionError, useAction, usePage } from "@kolektiv/keel-vue"
const seed = usePage()
const setName = useAction("harbor.setName")
const displayName = ref(seed.value.data.viewer?.displayName ?? "")
const errorText = computed(() => {
const error = setName.error.value
return error instanceof ActionError ? error.errors.displayName?.join(" ") : undefined
})
function onSetName() {
void setName.mutateAsync({ displayName: displayName.value }).catch(() => undefined)
}
</script>
<template>
<form @submit.prevent="onSetName">
<input v-model="displayName" />
<p v-if="errorText">{{ errorText }}</p>
<button type="submit" :disabled="setName.isPending">Save</button>
</form>
</template>import { createSignal } from "solid-js"
import { ActionError, useAction, usePage } from "@kolektiv/keel-solid"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"
export default function SetName() {
const seed = usePage<HomePage>()
const setName = useAction<SetNameIn, SetNameOut>("harbor.setName")
const [displayName, setDisplayName] = createSignal(seed().data.viewer?.displayName ?? "")
function onSetName(event: SubmitEvent) {
event.preventDefault()
void setName.mutateAsync({ displayName: displayName() }).catch(() => undefined)
}
return (
<form onSubmit={onSetName}>
<input value={displayName()} onInput={(event) => setDisplayName(event.currentTarget.value)} />
{setName.error instanceof ActionError && (
<p>{setName.error.errors.displayName?.join(" ")}</p>
)}
<button type="submit" disabled={setName.isPending}>Save</button>
</form>
)
}import { createSignal } from "solid-js"
import { ActionError, useAction, usePage } from "@kolektiv/keel-solid"
export default function SetName() {
const seed = usePage()
const setName = useAction("harbor.setName")
const [displayName, setDisplayName] = createSignal(seed().data.viewer?.displayName ?? "")
function onSetName(event) {
event.preventDefault()
void setName.mutateAsync({ displayName: displayName() }).catch(() => undefined)
}
return (
<form onSubmit={onSetName}>
<input value={displayName()} onInput={(event) => setDisplayName(event.currentTarget.value)} />
{setName.error instanceof ActionError && (
<p>{setName.error.errors.displayName?.join(" ")}</p>
)}
<button type="submit" disabled={setName.isPending}>Save</button>
</form>
)
}import { useState } from "preact/hooks"
import { ActionError, useAction, usePage } from "@kolektiv/keel-preact"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"
export default function SetName() {
const seed = usePage<HomePage>()
const setName = useAction<SetNameIn, SetNameOut>("harbor.setName")
const [displayName, setDisplayName] = useState(seed.data.viewer?.displayName ?? "")
return (
<form
onSubmit={(event) => {
event.preventDefault()
void setName.mutateAsync({ displayName }).catch(() => undefined)
}}
>
<input value={displayName} onInput={(event) => setDisplayName(event.currentTarget.value)} />
{setName.error instanceof ActionError && (
<p>{setName.error.errors.displayName?.join(" ")}</p>
)}
<button type="submit" disabled={setName.isPending}>Save</button>
</form>
)
}import { useState } from "preact/hooks"
import { ActionError, useAction, usePage } from "@kolektiv/keel-preact"
export default function SetName() {
const seed = usePage()
const setName = useAction("harbor.setName")
const [displayName, setDisplayName] = useState(seed.data.viewer?.displayName ?? "")
return (
<form
onSubmit={(event) => {
event.preventDefault()
void setName.mutateAsync({ displayName }).catch(() => undefined)
}}
>
<input value={displayName} onInput={(event) => setDisplayName(event.currentTarget.value)} />
{setName.error instanceof ActionError && (
<p>{setName.error.errors.displayName?.join(" ")}</p>
)}
<button type="submit" disabled={setName.isPending}>Save</button>
</form>
)
}import { html } from "lit"
import { ActionError, KeelElement, useAction } from "@kolektiv/keel-lit"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"
export default class SetName extends KeelElement<HomePage> {
readonly setName = useAction<SetNameIn, SetNameOut>(this, "harbor.setName")
private displayName = this.page.data.viewer?.displayName ?? ""
render() {
return html`
<form
@submit=${(event: SubmitEvent) => {
event.preventDefault()
void this.setName.mutateAsync({ displayName: this.displayName }).catch(() => undefined)
}}
>
<input
.value=${this.displayName}
@input=${(event: Event) =>
(this.displayName = (event.target as HTMLInputElement).value)}
/>
${this.setName.error instanceof ActionError
? html`<p>${this.setName.error.errors.displayName?.join(" ")}</p>`
: ""}
<button type="submit" ?disabled=${this.setName.isPending}>Save</button>
</form>
`
}
}import { html } from "lit"
import { ActionError, KeelElement, useAction } from "@kolektiv/keel-lit"
export default class SetName extends KeelElement {
readonly setName = useAction(this, "harbor.setName")
displayName = this.page.data.viewer?.displayName ?? ""
render() {
return html`
<form
@submit=${(event) => {
event.preventDefault()
void this.setName.mutateAsync({ displayName: this.displayName }).catch(() => undefined)
}}
>
<input
.value=${this.displayName}
@input=${(event) => (this.displayName = event.target.value)}
/>
${this.setName.error instanceof ActionError
? html`<p>${this.setName.error.errors.displayName?.join(" ")}</p>`
: ""}
<button type="submit" ?disabled=${this.setName.isPending}>Save</button>
</form>
`
}
}import { Component } from "@angular/core"
import { ActionError, injectKeelAction, injectKeelPage } from "@kolektiv/keel-angular"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"
@Component({
selector: "app-set-name",
standalone: true,
template: `
<form (submit)="onSetName($event)">
<input [value]="displayName" (input)="displayName = $any($event.target).value" />
@if (errorText) {
<p>{{ errorText }}</p>
}
<button type="submit" [disabled]="setName.isPending()">Save</button>
</form>
`,
})
export default class SetName {
readonly page = injectKeelPage<HomePage>()
readonly setName = injectKeelAction<SetNameIn, SetNameOut>("harbor.setName")
displayName = this.page().data.viewer?.displayName ?? ""
get errorText(): string | undefined {
const error = this.setName.error()
return error instanceof ActionError ? error.errors.displayName?.join(" ") : undefined
}
onSetName(event: Event) {
event.preventDefault()
void this.setName.mutateAsync({ displayName: this.displayName }).catch(() => undefined)
}
}import { Component } from "@angular/core"
import { ActionError, injectKeelAction, injectKeelPage } from "@kolektiv/keel-angular"
@Component({
selector: "app-set-name",
standalone: true,
template: `
<form (submit)="onSetName($event)">
<input [value]="displayName" (input)="displayName = $any($event.target).value" />
@if (errorText) {
<p>{{ errorText }}</p>
}
<button type="submit" [disabled]="setName.isPending()">Save</button>
</form>
`,
})
export default class SetName {
readonly page = injectKeelPage()
readonly setName = injectKeelAction("harbor.setName")
displayName = this.page().data.viewer?.displayName ?? ""
get errorText() {
const error = this.setName.error()
return error instanceof ActionError ? error.errors.displayName?.join(" ") : undefined
}
onSetName(event) {
event.preventDefault()
void this.setName.mutateAsync({ displayName: this.displayName }).catch(() => undefined)
}
}Typegen emits the in/out types next to the page map:
export interface HarborActions {
"harbor.setName": { in: SetNameIn; out: SetNameOut }
"harbor.postMessage": { in: PostMessageIn; out: PostMessageOut }
}
export type HarborActionId = keyof HarborActions
See Mutate and rehydrate for the invalidation rule.