Kilnx Grammar Reference
Kilnx has 27 keywords. The entire language fits on a single page.
For comparison: Python has 35 keywords and does none of these things without importing libraries. JavaScript has 64. Java has 67. Kilnx has 27 and delivers a complete web app from database to browser.
Hello World
page /
"Hello World"
One useful line. That's it.
Keywords
config
Global configuration. Database, port, secrets, upload limits.
config
database: env DATABASE_URL default "sqlite://app.db"
port: env PORT default 8080
secret: env SECRET_KEY required
uploads: ./uploads max 50mb
model
Defines data types and structure. The single source of truth. From a model, the language generates: CREATE TABLE, server validation, HTML forms, client validation, and listing fragments.
model user
name: text required min 2 max 100
email: email unique
role: option [admin, editor, viewer] default viewer
active: bool default true
created: timestamp auto
Relationships between models:
model post
title: text required min 5
body: richtext required
status: option [draft, published, archived] default draft
author: user required
created: timestamp auto
published_at: timestamp optional
model comment
body: text required
post: post required
author: user required
created: timestamp auto
custom fields from manifest
A model can declare a versioned manifest of runtime-extensible
fields. The manifest file (*_fields.kilnx) is edited by the
developer and parsed at startup. Fields are stored as JSON in a
custom column (TEXT on SQLite,
JSONB on PostgreSQL).
model deal
name: text required
custom fields from "deal_fields.kilnx"
Manifest syntax:
field revenue
kind: number
label: "Revenue"
required: false
mode: column // promotes to a real DB column instead of JSON storage
field status
kind: option
option [open, won, lost]
Available kinds: text, number,
date, option, email,
phone, bool, richtext,
reference, image.
Access in templates: {q.custom.revenue}. Iterate:
{{each q.custom}} (yields name,
value, label, kind per
field).
Per-tenant manifests with runtime placeholders:
model quote
custom fields from "{user.tenant_id}_fields.kilnx" or "default_fields.kilnx"
kilnx check validates all hardcoded
{q.custom.X} references against the manifest. Dynamic-path
manifests are silenced.
dynamic fields (runtime-mutable)
A model can opt into DB-backed runtime field definitions. End-users
(not developers) can then create, edit, and delete custom fields without
touching .kilnx files and without an app restart.
model deal
name: text required
custom fields from "deal_base.kilnx" // optional static baseline
dynamic fields // enables DB-backed field management
When dynamic fields is declared, kilnx auto-creates a
_deal_field_defs table at migration time with columns:
id, name, kind,
label, required, options,
reference_model, tenant_id,
sort_order.
The runtime merges the static manifest (if any) with rows from
_deal_field_defs at request time. Static fields always win
on name collision.
The developer writes the field management UI using normal pages and actions:
page /admin/fields requires auth
query fields: SELECT * FROM _deal_field_defs ORDER BY sort_order
html
{{each fields}}
<div>{fields.label} ({fields.kind})</div>
{{end}}
action /admin/fields/create method POST requires auth
query: INSERT INTO _deal_field_defs (name, kind, label)
VALUES (:name, :kind, :label)
redirect /admin/fields
Static analysis: kilnx check emits a
warning (not an error) for hardcoded {q.custom.X}
references on dynamic-fields models. Use
kilnx check --db <url> to connect to a live database
and validate against actual field names.
DB fields are JSON-only: rows from
_field_defs always write to the custom column.
Column-mode (mode: column) is only available for static
manifest fields.
tenant scoping
A model can declare that its rows belong to a tenant (another model)
with the tenant: directive. The directive must appear
before any field.
model org
name: text required unique
model user
tenant: org
email: email unique
password: password required
model quote
tenant: org
number: text required unique
total: float default 0
The compiler auto-synthesizes a required reference field for the
tenant (so tenant: org adds an org_id foreign
key column) and the runtime rewrites SELECT queries against
a tenant-scoped table to include
WHERE <table>.<tenant>_id = :current_user.<tenant>_id.
When the query already has a WHERE, the tenant predicate is
joined with AND.
The rewriter fails closed: if the SQL shape is too
complex for the built-in rewriter to verify (CTEs, JOINs, subqueries,
UNION, comments, schema-qualified tables, multi-statement queries), the
query is refused at runtime rather than silently passing through
unscoped. Refactor the query into a simpler single-table SELECT or bind
the tenant predicate yourself with
WHERE ... AND <col> = :current_user.<tenant>_id
and the rewriter will see it.
Mutations (INSERT, UPDATE,
DELETE) on a tenant-scoped table must bind the tenant
column textually; otherwise the runtime rejects them. Example that
passes:
action /quotes/create method POST requires auth
query: INSERT INTO quote (org_id, number)
VALUES (:current_user.org_id, :n)
The parser synthesizes the <tenant>_id column
automatically, and kilnx check flags references to
undefined tenant models and models that set themselves as their own
tenant.
This is defense in depth, not a substitute for application-level authorization. The rewriter closes the "forgot the tenant predicate" failure mode; other access-control concerns remain the developer's responsibility.
composite unique
For uniqueness spanning more than one field, declare a model-level
unique (...) directive:
model membership
user: user required
project: project required
role: option [owner, admin, member] default member
unique (user, project)
The directive takes two or more field names (single-field uniqueness
uses the field-level unique constraint). Reference fields
resolve to their <name>_id column. Migration emits
CREATE UNIQUE INDEX IF NOT EXISTS "uq_<table>_<col>_<col>" ON "<table>" (...),
which is idempotent on both SQLite and PostgreSQL. Multiple
unique (...) lines are allowed for independent groups. The
analyzer rejects unknown field names, fields repeated within a group,
and duplicate groups.
non-unique indexes
For query acceleration without a uniqueness requirement, declare an
index (...) directive:
model order
customer: customer required
created: timestamp auto
status: option [pending, paid, shipped]
index (customer, created)
index (status)
Single-column and multi-column indexes are both supported. Migration
emits
CREATE INDEX IF NOT EXISTS "ix_<table>_<cols>" ON "<table>" (...).
The ix_ prefix distinguishes non-unique indexes from the
uq_ prefix used by composite UNIQUE constraints. The same
analyzer rules apply as for unique (...).
permissions
Access rules by role.
permissions
admin: all
editor: read post, write post where author = current_user
viewer: read post where status = published
auth
Authentication configuration. Declarative, not code.
auth
table: users
identity: email
password: password_hash
login: /login
after login: /dashboard
superuser: env SUPERUSER_EMAIL
superuser designates a platform operator identity
(resolved via env VAR or a literal string). A superuser
bypasses all role checks — they can access any route regardless of
requires clauses.
requires clauses
requires accepts a comma-separated list of predicates.
All must pass (AND semantics).
page /admin requires auth // any logged-in user
page /admin requires admin // named role
page /admin requires superuser // platform operator only
page /app requires auth, :current_user.plan in ['cad','full'] // expression
page /app requires admin, :current_user.active == 'true' // combined
Expression predicates are prefixed with : and
support:
current_user.fieldName in ['a','b','c']— membership checkcurrent_user.fieldName == 'value'/!=/>/</>=/<=and/orconjunctions
Fields are resolved from the session user row
(current_user.X or bare X).
layout
Page wrapper templates. Four placeholders are available:
{page.title}- the page title (HTML-escaped){page.content}- the rendered page body{nav}- auto-generated navigation bar{kilnx.js}- required htmx and SSE scripts. Without this, htmx functionality breaks.
layout main
html
<html>
<head>
<title>{page.title}</title>
{kilnx.js}
</head>
<body>
{nav}
{page.content}
</body>
</html>
page
GET route that returns full HTML. The basic unit of the language.
page /users layout main title "Users"
query users: select name, email from user
html
{{each users}}
<div class="user">
<strong>{name}</strong>
<span>{email}</span>
</div>
{{end}}
Nested {{each}} blocks can reference fields from outer
loops with {^field} (one level up), {^^field}
(two levels), etc.:
page /user/:id
query u: select id, name from user where id = :id
query posts: select id, title from post where user_id = :id
html
{{each u}}
<h1>{name}</h1>
{{each posts}}
<a href="/user/{^id}/posts/{id}/delete">delete {title}</a>
{{end}}
{{end}}
With auth:
page /dashboard requires auth
query stats: select count(*) as total from orders
"Welcome back. You have {stats.total} orders."
action
POST/PUT/DELETE route that mutates data.
action /users/:id/archive method POST requires auth
query: update users set archived = true where id = :id
respond fragment user-card query:
select name, email from users where id = :id
fragment
Reusable piece of HTML for htmx to swap in the DOM.
fragment /users/:id/card
query user: select name, email from users where id = :id
html
<div class="card">
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
stream
Server-Sent Events for realtime updates.
stream /notifications requires auth
query: select message, created_at from notifications
where user_id = :current_user.id
and seen = false
every 5s
socket
Bidirectional WebSocket.
socket /chat/:room requires auth
on connect
query: select message, author.name, created
from chat_message
where room = :room
order by created desc
limit 50
send history
on message
validate
body: required max 500
query: insert into chat_message (body, author, room)
values (:body, :current_user.id, :room)
broadcast to :room fragment chat-bubble
api
JSON endpoint. Same grammar as page, but returns JSON instead of HTML.
api /api/v1/posts requires auth
query posts: select id, title, status, author.name, created
from post
where status = published
order by created desc
paginate 50
api /api/v1/posts method POST requires editor
validate
title: required min 5
body: required
query: insert into post (title, body, author, status)
values (:title, :body, :current_user.id, draft)
respond status 201
webhook
Receives external events.
webhook /stripe/payment secret env STRIPE_SECRET
on event payment_intent.succeeded
query: update order set status = paid
where stripe_id = :event.id
send email to query: select email from user
where id = :event.customer_id
template: payment-received
subject: "Payment confirmed"
Use on event * as a catch-all to match any event
type:
webhook /github secret env GITHUB_SECRET
on event *
query: insert into webhook_log (event, payload, received)
values (:event.type, :event.body, now())
fetch
Outbound HTTP from a page, action, job or schedule. Official escape hatch for calling external services without leaving the language.
action /orders/:id/charge method POST requires auth
fetch payment: POST https://api.stripe.com/v1/charges
header Authorization: env STRIPE_SECRET
header Content-Type: application/json
body amount: :total
body currency: usd
on payment.ok
query: update orders set charge_id = :payment.id
where id = :id
redirect /orders/:id
on not payment.ok
redirect /orders/:id/failed
The response JSON is flattened under the chosen name
(payment.id, payment.status, ...). Every fetch
additionally exposes :<name>.status_code and
:<name>.ok ("true" for 2xx) for
on branching. Set
Content-Type: application/json to send a JSON body with
typed numbers and booleans; otherwise the body is form-urlencoded.
Inside an action a transport-level failure (DNS, timeout, refused
connection) rolls back the implicit transaction and returns
502. HTTP 4xx / 5xx are not transport errors and let the
action keep running so it can react via on.
body and header values may invoke builtin
functions (round, slugify,
bcrypt, sha256, uuid,
format, ...). The shape name(args) opts into
expression mode; plain :param templates are unchanged. See
## Builtin Functions in FEATURES.md for the full
registry.
schedule
Timed tasks running inside the same binary.
schedule cleanup every 24h
query: delete from session where expires_at < now()
schedule report every monday at 9:00
query stats: select count(*) as new_users from user
where created > now() - interval 7 days
send email to query: select email from user where role = admin
template: weekly-report
subject: "Weekly report: {stats.new_users} new users"
job
Asynchronous background work.
job generate-report
query data: select * from order
where created > :start_date
and created < :end_date
generate pdf from template report data
send email to :requested_by
template: report-ready
attach: generated pdf
subject: "Your report is ready"
query
SQL inline or named. A top-level query <name>: SQL
defines a reusable named query that can be referenced by other blocks.
Inside a page, action, fragment, or api body,
query <name>: SQL binds the result for template
interpolation.
query active-users: select u.name, u.email, count(o.id) as orders
from users u
left join orders o on o.user_id = u.id
where u.active = true
group by u.id
page /users
query users: active-users
html
{{each users}}
<div class="user">
<strong>{name}</strong>
<span>{orders} orders</span>
</div>
{{end}}
validate
Declarative validation rules.
action /users/new method POST
validate
name: required
email: required, is email
query: insert into users (name, email) values (:name, :email)
redirect /users
paginate
Automatic pagination. The language generates pagination controls with htmx.
page /posts
query posts: select title, author.name from post
where status = published
order by published_at desc
paginate 20
html
{{each posts}}
<article>
<h2>{title}</h2>
<span>{author.name}</span>
</article>
{{end}}
send email
Declarative email sending.
action /users/invite method POST requires admin
validate
email: required, is email
query: insert into user (email, role, active)
values (:email, viewer, false)
send email to :email
template: invite
subject: "You've been invited"
redirect
Redirects to another route.
action /users/create method POST
validate user
query: insert into user (name, email) values (:name, :email)
redirect /users
on
Result handling for success, error, not found, forbidden.
action /users/:id/delete method POST requires auth
query: delete from users where id = :id
on success: redirect /users
on error: alert "Could not delete user"
on forbidden: redirect /login
limit
Rate limiting. Declarative.
limit /api/*
requests: 100 per minute per user
on exceeded: status 429 message "Too many requests"
limit /login
requests: 5 per minute per ip
on exceeded: status 429 message "Too many attempts"
delay 30s
log
Observability built in.
log
level: env LOG_LEVEL default info
slow-query: 100ms
requests: all
errors: all stacktrace
test
Declarative tests in the same language.
test "user can create post"
as editor
visit /posts/new
fill title "Test Post"
fill body "Content here"
submit
expect page /posts contains "Test Post"
expect query: select count(*) from post
where title = 'Test Post'
returns 1
translations
Internationalization.
translations
en
welcome: "Welcome back"
users: "Users"
pt
welcome: "Bem vindo de volta"
users: "Usuários"
config
default language: en
detect language: header accept-language
page /dashboard requires auth
"{t.welcome}, {current_user.name}"
enqueue
Dispatches an async job.
action /reports/generate method POST requires admin
validate
start_date: required, is date
end_date: required, is date
enqueue generate-report
start_date: :start_date
end_date: :end_date
requested_by: :current_user.email
respond fragment ".reports"
alert success "Report is being generated"
broadcast
Sends data to all connected WebSocket clients in a room. The fragment receives the same params that the socket handler received.
socket /chat/:room requires auth
on message
query: insert into chat_message (body, author, room)
values (:body, :current_user.id, :room)
broadcast to :room fragment chat-bubble
Complete App Example
config
database: env DATABASE_URL default "sqlite://app.db"
port: 8080
secret: env SECRET_KEY required
model user
name: text required
email: email unique
password: password required
role: option [admin, user] default user
created: timestamp auto
model task
title: text required
done: bool default false
owner: user required
created: timestamp auto
auth
table: user
identity: email
password: password
login: /login
after login: /tasks
layout main
html
<html>
<head>
<title>Tasks</title>
{kilnx.js}
</head>
<body>
{nav}
{page.content}
</body>
</html>
page /tasks layout main requires auth
query tasks: select id, title, done from task
where owner = :current_user.id
order by created desc
paginate 20
html
<input type="search" name="q" placeholder="Search tasks..."
hx-get="/tasks" hx-trigger="keyup changed delay:300ms"
hx-target="#task-list">
<table id="task-list">
<tr><th>Title</th><th>Done</th><th></th></tr>
{{each tasks}}
<tr>
<td>{title}</td>
<td>{{if done}}Yes{{end}}</td>
<td><button hx-post="/tasks/{id}/delete" hx-target="closest tr" hx-swap="outerHTML">Delete</button></td>
</tr>
{{end}}
</table>
page /tasks/new layout main requires auth
html
<form method="POST" action="/tasks/create">
<label>Title <input type="text" name="title" required></label>
<button type="submit">Create</button>
</form>
action /tasks/create method POST requires auth
validate task
query: insert into task (title, owner)
values (:title, :current_user.id)
redirect /tasks
action /tasks/:id/delete method POST requires auth
query: delete from task where id = :id and owner = :current_user.id
respond fragment ".task-list" query:
select id, title, done from task
where owner = :current_user.id
order by created desc