A friend asked me, around the third week of working on Kilnx, why I had not just written it as an Express plugin. The question was fair. It was also the same question my own brain had been asking me for a month.
The honest answer took me longer to find than I would like to admit. This piece is what I would have said to him in November if I had already understood what I was doing.
The push
I had been shipping backends. Some of them were small. Most of them were not. A multi-tenant CRM where every query had to filter by org_id or you leaked data across customers. An internal admin tool with role-based pages, scheduled jobs, outbound webhooks signed with HMAC. A SaaS dashboard with background workers, rate-limited APIs, an LLM call inside a critical path. None of these are blogs. All of them carried the same shape.
The interesting work in each project was the domain. The uninteresting work was identical. Auth setup. Session management. CSRF wiring. Connection pool tuning. Migration script naming. Multi-tenant guards on every query. Webhook signature verification. The right way to call Claude from a background job without burning the bill on retry. By the time I had a first feature shipped, I had touched a dozen files and made forty decisions, none of which had anything to do with what the customer wanted.
I started counting. Two thirds of the lines in those projects were about plumbing. The other third was the product. The numbers held across three projects. The numbers held across two stacks. The plumbing was not a project artifact. It was the toolchain's signature, and it was showing up in every project I touched.
That is what pushed me toward a language. Not a feeling. A pattern that did not move when I changed teams, customers, or stacks.
Constitution
The repo has a file called PRINCIPLES.md. The first principle, numbered zero because it predates the others, reads:
The complexity is the tool's fault, not the problem's. Most web apps are not complex. They are lists, forms, dashboards, CRUDs. The complexity comes from the tools we use, not from the problem we are solving. Kilnx exists to prove this.
That sentence is a claim. The rest of the language is the test of the claim. If you accept the premise, the design follows. If you reject the premise, nothing about Kilnx makes sense. The interesting argument is whether the premise is true, not whether the design is clever.
I think it is mostly true. Not entirely. Some web work is genuinely complex and would be complex in any tool. But the line between "complex problem" and "complex tool" runs further toward the tool side than most engineers want to admit, and the way to find out which side a given complexity sits on is to build a tool that subtracts itself and see what remains.
Here is what a working slice of the language looks like. Authenticated task list, htmx delete, paginated query, all in one file.
model task
title: text required
done: bool default false
created: timestamp auto
auth
table: user
identity: email
password: password
login: /login
after login: /tasks
page /tasks requires auth
query tasks: SELECT id, title, done FROM task
WHERE owner = :current_user.id
ORDER BY created DESC paginate 20
html
{{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}}
action /tasks/:id/delete requires auth
query: DELETE FROM task WHERE id = :id AND owner = :current_user.id
respond fragment delete
That file is the whole app. Registration, login with bcrypt, sessions, CSRF on the htmx POST, parameter binding on every query, pagination, ownership check on delete. The Express equivalent is between four hundred and six hundred lines across eight files, depending on which middleware you copy versus extract.
The obvious objection
Build a framework, not a language. Rails exists. Phoenix exists. Django exists. Whatever you think is broken about backend work, somebody already wrapped your favorite host language in a thinner thing and called it a framework. Pick one and contribute.
That was the version of the argument I kept hearing, including from myself. It did not land for one structural reason. Frameworks always lose the constraint fight, and the constraint fight is exactly the fight Kilnx is trying to win.
A framework lives inside the host language. The host language gives you escape hatches at every level. You want to bypass the router? Reach for the HTTP server. You want to skip the ORM? Drop into raw SQL. You want to ignore the tenant guard? Comment it out, the language will let you. Every framework I have shipped real work on, by month six, has half its codebase in the official patterns and the other half in escape hatches. The escape hatches are not bugs. They are the price the framework pays to live as a tenant inside a general-purpose language.
I did not want a tenant. I wanted a contract.
What a language can refuse
Five things turned out to be impossible inside a framework that became natural inside a language.
Compile-time SQL safety. In any framework, your SQL lives in strings, or in an ORM that compiles strings, or in a query builder that pretends not to. The framework cannot validate your queries at compile time because the host language cannot see them at compile time. Kilnx queries are parsed by the same compiler that parses the rest of the program. A column rename in a model fails to compile every query that referenced it. SQL injection is not blocked by an escape function. It is blocked by the grammar refusing to interpolate untyped strings into SQL position.
Multi-tenant guards as syntax. Every SaaS backend I have shipped had the same bug class. Someone forgot to add WHERE org_id = :current_user.org_id to a query, and one tenant could read another tenant's data. The bug class exists because the host language sees the missing filter as legal code. In Kilnx, a tenant modifier on the model produces a fail-closed guard that the analyzer enforces on every query path. If you write a query that does not scope to the tenant, the compiler refuses to build the binary.
model invoice tenant
amount: int required
customer: text required
page /invoices requires auth
query invoices: SELECT id, amount, customer FROM invoice
html
{{each invoices}}<p>{customer}: {amount}</p>{{end}}
$ kilnx check app.kilnx
error: query in page /invoices is missing tenant scope
app.kilnx:5: query invoices: SELECT id, amount, customer FROM invoice
hint: tenant model 'invoice' requires WHERE org_id = :current_user.org_id
hint: or use `unscoped: explicit-reason` to opt out for a specific query
A framework can warn you. A language can refuse to build. The pattern is documented in the tenant rollout PR.
LLM agents as first-class language constructs. The piece I wrote last week argued that agents in production end up as tasks on the orchestrator you already run. The same logic applies one layer down. An agent inside a request handler is a task on the language you already run.
mcp linear
command: linear-mcp-server
env: LINEAR_API_KEY=:env.LINEAR_API_KEY
action /tickets/:id/triage
agent classify
prompt: "Classify ticket {ticket.body} into one of: bug, feature, support."
permission-mode: plan
max-budget-usd: 0.25
max-turns: 3
mcp: linear
query: UPDATE ticket SET category = :classify.text, cost_usd = :classify.cost_usd
WHERE id = :id
respond fragment ticket-row
agent spawns a Claude CLI subprocess. :classify.text, :classify.session_id, :classify.cost_usd, :classify.stop_reason are bound for the rest of the action. max-budget-usd is enforced by the runtime. mcp: linear mounts the MCP server declared at the top of the file. The frame around the agent is grammar, not glue.
Migrations as a controlled surface. kilnx migrate detects drift across five dimensions: orphan columns, type mismatch, NOT NULL mismatch, single-column UNIQUE mismatch, DEFAULT presence mismatch. Migrations themselves are additive, never destructive. The language took a position on what is safe to do automatically and what requires the human to look.
$ kilnx migrate app.kilnx
applying schema...
warning: orphan column
invoice.legacy_status (DB has it, model does not declare it)
hint: drop manually after data migration
warning: type mismatch
user.id (DB: integer, model: uuid)
hint: requires data migration plus ALTER, not auto-generated
warning: NOT NULL mismatch
task.due_date (DB: nullable, model: required)
hint: backfill defaults before tightening
warning: UNIQUE mismatch
account.slug (DB: not unique, model: unique)
hint: dedupe rows before adding the constraint
warning: DEFAULT presence mismatch
task.done (DB: no default, model: default false)
hint: review before relying on the default in new code
migration applied with 5 warnings.
A framework can ship a migration tool. A language can make the migration tool part of the same compile pass that builds your routes.
Single-binary deploy. A framework runs on top of a runtime that you also have to deploy. Node. Python. Ruby. Each brings a package manager, a lockfile, a Dockerfile, a node_modules directory the size of a small operating system. Kilnx compiles a .kilnx file to a fifteen-megabyte binary that embeds the HTTP server, the database driver, the htmx JavaScript, and your application. scp it to a server and run it. The deploy story is ./myapp. A framework can shrink the deploy story. A language can collapse it.
Notice the pattern. A framework can make these things easier. A language can make their alternatives impossible. The asymmetry is the whole point.
What it costs
Building a language is more work than building a framework. This is the easy half of the trade-off to name. The repo is nineteen thousand lines of Go and three hundred eleven tests with race detection, to deliver something whose feature list on paper looks like a slightly opinionated web framework. If a small web framework was what I wanted, building the framework would have been the right answer.
The harder half is that a language has to take itself seriously. The grammar has to be coherent. The error messages have to be useful. The tooling has to exist. There is no falling back on someone else's ecosystem when something is missing. You either ship the LSP server or your users do not get autocomplete. You either ship the test runner or your users do not get tests. You either ship the playground or your users cannot evaluate the language without installing it. You either auto-generate an AGENTS.md for coding agents or your users get LLMs inventing keywords that do not exist.
The third cost is that a language refuses things, and refusing things is socially expensive. Every refusal is a fight with somebody who has a perfectly reasonable use case that the language does not serve. Frameworks can absorb those use cases with an escape hatch. Languages cannot. You have to look someone in the eye and tell them the language is not going to do that, and that the reason is that doing it would break the contract.
A friend told me that the part of building a language nobody warns you about is that you have to say no to a lot of people who are right.
What it gives back
The give-back is the part that justifies the cost. It is also the part that does not fit on a marketing page, because it has to be measured rather than read about. So measure.
The blog example in the repo is ninety-four lines in a single file. The Express equivalent is between four hundred and six hundred lines across eight files, depending on which middleware you copy versus extract.
Kilnx blog Express + Prisma + EJS blog
────────────────── ──────────────────────────────
app.kilnx 94 app.js 62
routes/auth.js 88
routes/posts.js 104
models/Post.js 36
middleware/csrf.js 24
middleware/session.js 31
db/migrations/ 47
views/ 94
────────── ───
8 files 486
1 file 94 ~480
The Kilnx version is short because the language absorbed the rest, not because the app does less. The same things ship in both columns. The difference is which side wrote them.
What disappears on the Kilnx side, never written by the user:
bcrypt password hashing auto from `auth`
session cookies, signed auto from `auth`
CSRF on every POST/PUT/DELETE auto on every action
SQL parameter binding only form the grammar allows
HTML escaping in templates only form the grammar allows
multi-tenant scoping refused at compile time if missing
schema migrations same compile pass that builds routes
LLM agent budget enforcement required attribute on `agent` blocks
MCP server lifecycle managed by the runtime
HTTP server, routing, logs embedded in the binary
The other give-back is harder to measure but bigger. The constraints stop you from drifting. There is no point at which you can decide to do auth a different way and have it cost you nothing. The decision was made when the language was designed. You inherit it. The cognitive load of every project drops because the design space is smaller.
For most product work, smaller design space is the gift you have been begging the universe for.
When a framework is the right answer
The inverse is real and worth naming.
If your work needs escape hatches more often than it needs constraints, a framework is the right shape. Custom integrations against an irregular set of third-party systems, custom protocols, custom transport, custom auth flows that do not fit any standard pattern. Days that are ninety percent edge cases. A framework lets you write the edge case directly in the host language without the language fighting you.
Convex made the same trade in the agent world. They accepted the determinism contract, and they paid the cost of not being able to do arbitrary side effects in mutations. For most product workloads that cost is fine. For some, it is too high. The same logic applies here. Kilnx accepts a constraint contract, and the contract is wrong for some workloads. The question is whether your workload is one of them, and the honest answer is usually no.
What the bet really is
The bet at the center of Kilnx is that a specific opinion, taken seriously, eats a category of work that nobody wanted to be doing anyway. Pick the right opinion, encode it past the point where users can opt out, and the opinion becomes leverage. The leverage shows up as code that did not need to be written. Two thirds of every project, in my experience.
A framework can host an opinion. A language can enforce one. The reason I built a language is that I wanted the enforcement, and I had counted the lines of plumbing in enough projects to know what the enforcement was worth.
Kilnx is in early release. The grammar is twenty-seven keywords. The compiler is a few thousand commits old. None of that matters as much as the bet does, and the bet is what is being tested in production over the next year.
The spreadsheet was real. The language was the honest answer to it.
I am building Kilnx, a declarative backend language that pairs with htmx, and Provero, where a lot of the language-shape decisions I write about are the day job. If the diagnosis here lands, that is the door.
André Ahlert is a product engineer. Contributor across Apache, Flyte, Backstage, HTMX, Hyperscript. Currently building Kilnx and Provero.
Top comments (0)