The web framework your AI can't get wrong.
Routing, SQLite, templates, and safety live in the binary. You — or the model in your editor — write small Lua files and HTML. Edit a file, refresh the browser. No SQL injection, no XSS by accident.
One 11 MB binary. Nothing else to install.
$ effortless new crm Creating new Effortless project: crm ✓ Created project structure $ cd crm && effortless serve → http://localhost:3000
A complete CRUD page. 11 lines of Lua.
Three files. One declares the table, one handles the requests, one renders the page. There is no controller, no model, no migration, no config, and no build step.
local t = require "effortless.types" return { todos = { id = t.pk, user_id = t.ref "users", title = t.text { required = true }, completed = t.boolean { default = false }, created_at = t.now, indexes = { "user_id", "completed", "created_at" } } }
local auth = require "effortless.auth" local eql = require "effortless.eql" function guard(request) return auth.check() end function get(request) return { todos = eql "select todos where .user_id = $auth.id order by created_at desc" } end actions = { add = eql.mutate "insert todos { title: $form.title, user_id: $auth.id }", toggle = eql.mutate "update todos set { completed: not .completed } where .id = $form.id and .user_id = $auth.id", delete = eql.mutate "delete todos where .id = $form.id and .user_id = $auth.id", }
{{ layout "base" }} {{ title = "Todos" }} <div class="max-w:672 mx:auto py:32 px:16"> <form method="POST" class="flex:between|center gap:8 mb:24"> {{ csrf_field | raw }} <input type="hidden" name="action" value="add"> <input name="title" value="{{ old.title }}" class="w:full px:16 py:8 b:#d1d5db|1 r:8" placeholder="New todo"> <button class="px:16 py:8 bg:blue c:white r:8">Add</button> </form> {{ if errors.title }}<p class="c:#b91c1c f:14 mb:16">{{ errors.title }}</p>{{ /if }} {{ for todo in todos }} <form method="POST" class="flex:between|center py:8"> {{ csrf_field | raw }} <input type="hidden" name="id" value="{{ todo.id }}"> <span>{{ todo.title }}</span> <span class="flex gap:8"> <button name="action" value="toggle" class="f:14 c:#4b5563">{{ if todo.completed }}undo{{ else }}done{{ /if }}</button> <button name="action" value="delete" class="f:14 c:#b91c1c">delete</button> </span> </form> {{ empty }} <p class="c:#6b7280">No todos yet.</p> {{ /for }} </div>
Authenticated. CSRF-protected. Scoped to the logged-in user. Validated against the
schema you declared. Paste those three files into a fresh
effortless new project and the page works — the framework's
own conventions guide ships the same code, and a spec runs it.
Why a model writes this well.
"AI-native" usually means a chat box bolted onto a framework. Here it means four structural choices, each one made because a model pays a cost a human doesn't.
Zero build means cheap iterations.
A human pays the toolchain tax once, at setup. A model pays it on every loop. Edit a file, hit the route — that's the entire cycle, and there's no dev server to restart.
A small, closed API surface.
Five Lua functions. Ten template tags. One query language. When the surface is this
narrow, mistakes stop being open-ended and become the kind
effortless check catches.
The guarantees a reviewer skips.
SQL injection is inexpressible — there is no raw SQL. XSS is opt-in, through a
| raw you have to type. CSRF is enforced by the framework,
not by your memory. Review concentrates on "is the logic right," which is the only
part humans review well.
Disposable environments.
SQLite plus one binary means the whole app boots in a temp directory in milliseconds. Every verification command in the next section exploits that.
Your AI can't see the browser. Give it eyes.
Four one-shot commands, every one with --json. This is how a
model finds out it was wrong in two seconds instead of finding out from you in two days.
effortless check
Static analysis over the whole project: Lua syntax, schema declarations, EQL literals, route conventions, tenancy scoping.
$ effortless check ✓ No issues found
effortless eval
One Lua snippet in real app context, with the database attached. Prints JSON.
$ effortless eval 'return { todos = eql.count "select todos" }' {"todos":0}
effortless request
A real request through the real router, in-process, no server.
--data returns the handler's data instead of rendered
HTML; --as impersonates a user.
$ effortless request GET /todos --data --as 1 HTTP 200 {"path":"/todos","method":"GET","logged_in":true, "todos":[{"id":1,"title":"Ship the website","completed":false,"user_id":1}]}
effortless test
The project's own _tests/*.lua suites, each against a
fresh temporary database.
$ effortless test ✓ guest is redirected to login (_tests/todos_test.lua) ✓ a logged-in user sees their todos (_tests/todos_test.lua) 2 passed, 0 failed
routes/todos/todos.lua:4: [tenancy] select on "todos" should filter .user_id to $auth (or mark the call `-- eql: unscoped`)
Every error names the rule and the escape hatch. That's the house standard for error copy in this framework — a message that only says what's wrong is treated as a bug.
The easy queries look like SQL. The complex ones don't.
On a simple select, EQL is SQL with a dot in front of the column. That's on purpose — you shouldn't have to learn anything to read it. It earns its keep on the queries where SQL alone isn't enough and you end up writing glue around it.
The basics, for orientation
| SQL | EQL |
|---|---|
| SELECT * FROM todos WHERE user_id = ? | select todos where .user_id = $user_id |
| SELECT id, title FROM todos | select todos { id, title } |
| SELECT COUNT(*) | eql.count "select todos where …" |
| INSERT INTO todos (title) VALUES (?) RETURNING * | insert todos { title: $title } |
| UPDATE todos SET done = 1 WHERE id = ? | update todos set { completed: true } where .id = $id |
| DELETE FROM todos WHERE id = ? | delete todos where .id = $id |
Parents with their children, in one query.
Nested projections follow your schema's foreign keys and come back as real nested tables. No join to dedupe, no N+1, no JSON string to decode.
What you write otherwise
select users.id, users.name,
(select json_group_array(json_object(
'title', t.title,
'completed', t.completed))
from todos t
where t.user_id = users.id) as todos
from users;
With EQL
local users = eql [[ select users { id, name, todos { title, completed } } ]]
[{ "id": 1, "name": "Ada Lovelace", "todos": [
{ "title": "Ship the website", "completed": false },
{ "title": "Write the docs", "completed": true }
] }, … ]
A search box that's usually empty.
and? marks a filter as optional: the whole group disappears
when every parameter in it is null or empty. One query string serves the empty search
box and the filled one — which is why you never assemble a WHERE clause by hand, and
why injection has nowhere to enter.
What you write otherwise
local sql = "select * from contacts where owner_id = ?"
local args = { user.id }
if q and q ~= "" then
sql = sql .. " and (name like ? or email like ?)"
args[#args + 1] = "%" .. q .. "%"
args[#args + 1] = "%" .. q .. "%"
end
sql = sql .. " order by created_at desc"
local rows = db:query(sql, args)
With EQL
local rows = eql([[select contacts where .owner_id = $auth.id and? (.name like $q or .email like $q) order by created_at desc]], { q = request.query.q })
q = "" → Alan Turing, Katherine Johnson, Radia Perlman q = "ala" → Alan Turing
Re-importing a spreadsheet, safely.
Spread a whole row of untrusted values with $row: unknown
keys and columns marked protected are dropped before
validation, explicit keys beat spread keys, and
on conflict … update turns the second import into an update
instead of a duplicate.
What you write otherwise
-- never pass the CSV row straight through
local safe = {
name = row.name,
email = row.email,
}
db:exec([[
insert into contacts (owner_id, name, email)
values (?, ?, ?)
on conflict (email) do update set
name = excluded.name,
email = excluded.email
]], { user.id, safe.name, safe.email })
With EQL
eql [[ insert contacts { $row, owner_id: $auth.id } on conflict email update ]]
row = { name, email, role = "admin", action = "add" } ✓ role and action dropped · owner_id forced to $auth.id ✓ second import updates row 3, does not duplicate it
Every write above is validated against the schema before it runs, scoped to
$auth.id where the table is tenant-owned — skipping that
scope is an effortless check error — and parameterized
whether you passed a params table or not. There is no raw SQL underneath to fall back to.
Batteries, not build tools.
Everything below ships inside the same 11 MB executable. There is no plugin directory,
no package manifest, and nothing to npm install.
routes/
File-based routing with dynamic and catch-all segments, plus root middleware.
EQL
Always-parameterized queries with schema-validated writes, relations, and aggregates.
.x.html
Liquid-style templates, auto-escaped, with layouts, partials, and 20+ modifiers.
_schema/schema.lua
Desired-state schema; the reconciler plans and applies the diff.
auth
bcrypt sessions, roles, guards, 7-day cookies. First registered user becomes admin.
csrf
Framework-level. A mutating POST without the token is a 403, not a bug you find later.
/_admin
CRUD grid and schema editor, zero config, admin role only.
files
Uploads with size limits, saved and served without a storage service.
http · proc
Outbound requests and subprocesses — argv arrays, never a shell string.
log driver by default, Bento for transactional sending.
ai
OpenAI-compatible chat and structured JSON, for what your app does — not for writing it.
realtime
Server-sent events for live refresh, Pusher-compatible WebSockets for presence.
_commands/ · _cron/
Named one-shot operations and scheduled jobs, each in its own process.
effortless lsp
A language server for the DSLs: completion, diagnostics, formatting, semantic highlighting, schema-aware hover inside EQL strings, and mingled classes that show their generated CSS.
The admin you didn't write.
Every project gets /_admin. It's one grid where the column
header is the schema — rename a field, change its type, add an index, and the
change is planned, applied to SQLite, and written back into
_schema/schema.lua, so the file and the database can't
disagree. Destructive edits show you the data loss and the SQL before they run. Only
users with role = "admin" see it, and there is nothing to
configure.
One file is the migration.
Declare the state you want. The reconciler diffs it against the live database and
converges — on every request in development, on
effortless migrate in production.
local t = require "effortless.types" return { todos = { id = t.pk, user_id = t.ref "users", title = t.text { required = true, min = 3, max = 120 }, status = t.enum { "new", "done", default = "new" }, completed = t.boolean { default = false }, created_at = t.now, indexes = { "user_id", "created_at" }, }, }
$ effortless migrate --dry-run + table users + table todos + index idx_todos_n_8e30210d0c6b on todos(user_id) + index idx_todos_n_d424f4640b15 on todos(completed) + index idx_todos_n_7e91ba89ba83 on todos(created_at)
There is no migrations folder — nothing to order, squash, or replay. Destructive steps are
flagged, refuse to run without --force, and write a backup
first. Renaming a column without losing its data is one declaration:
full_name = t.text { was = "name" }.
What it's not for.
Every framework site has this section. Most of them hide it in a FAQ.
Not a scaling story.
This is built for internal tools, admin panels, and CRUD apps — the software a team of five runs its work on. Performance tuning and horizontal scaling are non-goals; SQLite on one box is the whole deployment model.
If you're serving a million anonymous visitors, use something else and enjoy it.
Not in your model's training data.
EQL, .x.html templates, and mingled are all custom. Your
model is natively fluent in SQL, Jinja, and Tailwind, and it will reach for them here
and be wrong.
This is the framework's one real bet: trading training-data priors for a surface small enough to make mistakes catchable.
Not a JavaScript framework.
Server-rendered HTML, forms, and redirects. mingled for styling and impetus for interactivity, both from a script tag, both optional. No bundler, no hydration, no client router.
If your product needs a rich client, this isn't the shape for it.
Why the training-data bet is survivable
- 1. The DSLs hug conventions you already know. EQL is documented as a diff from SQL, templates as a diff from Liquid, mingled as a diff from Tailwind. No new syntax is invented where a convention exists.
- 2. The feedback loop catches DSL mistakes in seconds. That's what the verification ladder is for, and why every error message names the fix and not just the fault.
- 3. Each DSL has exactly one reference file, written as a conversion guide. Point your model at /llms.txt and it has all of them.
effortless new crm
One binary, one SQLite file, and code your model can actually get right.