The web framework your AI can't get wrong.
HTTP server, SQLite, query language, templates, auth and admin in one binary. You write Lua files and HTML; there is no build step. SQL injection is inexpressible. XSS is opt-in.
$ curl -fsSL https://effortless.run/install.sh | sh
$ effortless new crm Creating new Effortless project: crm ✓ Created project structure $ cd crm && effortless serve Starting Effortless server... → http://localhost:3000 ✓ Loading schema from _schema/schema.lua ✓ Loaded 3 routes
The whole thing
A complete CRUD page in 11 lines of Lua.
Three files: the table, the handler, the template. No controller, model, migration, config, or 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 current user, validated against the
schema. The same code ships in the conventions guide and runs in the framework's spec
suite; paste it into a fresh effortless new project and the
page works.
Admin
An admin for every table.
Every project serves /_admin. It reads the schema: a
reference column shows the related row's name, an enum shows its values, an image
column shows a preview and an upload button. There is nothing to configure.
Inline editing
Cells save on blur. Deletes are deferred with an Undo.
Record drawer
Open a row, edit it, and add related records from the same panel. Link tables are grouped at the foot of the sidebar.
Ask
A plain-language question becomes one validated, read-only EQL statement. The statement is shown, editable and re-runnable.
Sign in as any user
See the app as they see it. A bar names the account and links back.
Files on the row
A t.image cell shows a preview and an upload button. The object is deleted with the row.
Allowlisted
Only emails in SUPERADMINS reach it. Everyone else gets a 404.
Captured from a fresh effortless new project with a five-table CRM schema and seed data.
The Ask reply came from a stub model endpoint; the validation, the query and the rows are real.
Built for AI-written code
Four decisions made for code a model writes.
Each one exists because a model pays a cost a human doesn't.
No build step.
A human pays the toolchain cost once. A model pays it on every iteration. Edit a file, request the route; nothing to restart.
A small, closed API.
Five Lua functions, ten template tags, one query language. Mistakes are bounded,
and effortless check catches most of them.
Safety the reviewer does not have to check.
There is no raw SQL, so injection cannot be written. Output is escaped unless you
type | raw. CSRF is enforced by the framework. Review is
left with the logic.
Disposable environments.
One binary and one SQLite file: the whole app boots in a temp directory in milliseconds. Every verification command relies on that.
Verification
Four commands that replace the browser.
Each is one-shot and has --json. A model runs them after
every edit and reads the result.
01
effortless check
Static analysis over the whole project: Lua syntax, schema declarations, EQL literals against the schema, route conventions, tenancy scoping.
$ effortless check ✓ No issues found $ effortless check --json {"ok":true,"errors":[]}
02
effortless eval
One Lua snippet in real app context, with the database attached. Prints JSON.
$ effortless eval 'return { ok = true, todos = eql.count "select todos" }' {"todos":0,"ok":true}
03
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}]}
04
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:7: [tenancy] select on "todos" should filter .user_id to $auth (or mark the call `-- eql: unscoped`)
Declare a column owner = true and every query on that table
must filter by it, or carry -- eql: unscoped. Every error names
the rule and the escape hatch.
The query language
Simple queries read like SQL. The rest is shorter.
A plain select is SQL with a dot before the column name. The differences appear where SQL needs glue code around it.
The basics
| 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 d.* FROM deals d JOIN companies c ON c.id = d.company_id WHERE c.industry = ? | select deals where .companies.industry = $industry |
| 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 the schema's foreign keys and return 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 group is dropped when
every parameter in it is null or empty. One query string serves the empty and the
filled search box, so no WHERE clause is assembled by hand.
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.
$row spreads an untrusted row: unknown keys and
protected columns are dropped before validation, explicit
keys win over spread keys, and on conflict … update makes
the second import an update.
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 is validated against the schema before it runs, scoped to
$auth.id where the table declares an owner, and parameterized
in every case. Relations filter across any number of hops. There is no raw SQL escape
hatch.
Included
What ships in the binary.
No plugin directory, no package manifest, nothing to install.
File-based routing with dynamic and catch-all segments, plus root middleware.
Always-parameterized queries with schema-validated writes, relations across hops, aggregates and computed columns.
Liquid-style templates, auto-escaped, with layouts, partials, components and 20+ modifiers.
Desired-state schema. The reconciler plans and applies the diff; renames keep their data.
bcrypt sessions, roles, guards, 7-day cookies. Tenant tables declare an owner and the checker enforces it.
Framework-level. A mutating POST without the token is a 403.
A grid, a record drawer and a plain-language query box for every table. Allowlisted users only.
An upload is a column on the row that owns it. Local disk or Cloudflare R2; deleted with the row.
Outbound requests with SSRF protection and subprocesses as argv arrays, never a shell string.
Logs to stderr until a Bento key is set.
Chat, streaming and structured JSON against any OpenAI-compatible endpoint.
Server-sent events from a change outbox, scoped per subscriber.
Named one-shot operations and scheduled jobs, each in its own process.
Consistent backups, and a scrubbed copy of production pulled locally in one command.
A language server for the DSLs: diagnostics, completion, formatting, and schema-aware hover inside EQL strings.
Migrations
The schema file is the migration.
Declare the desired state. The reconciler diffs it against the database and applies
the difference: on each request in development, on
effortless migrate in production.
deals = { id = t.pk, owner_id = t.ref "users" { required = true, owner = true }, company_id = t.ref "companies", name = t.text { required = true, was = "title" }, amount = t.real { min = 0, default = 0 }, priority = t.enum { "low", "normal", "high", default = "normal" }, created_at = t.now, indexes = { "owner_id", "company_id" }, },
$ effortless migrate --dry-run ~ rename column deals.title → deals.name + column deals.priority TEXT $ effortless migrate --dry-run --json {"steps":[{"op":"rename_column","table":"deals", "detail":"~ rename column deals.title → deals.name", "destructive":false,"data_loss":[]}, …],"destructive":false}
No migrations folder. A rename keeps its data with was = "title".
Destructive steps list the rows they drop, require --force, and
write a backup first.
Limits
What it is not for.
Three things it does not try to do.
Not a scaling story.
Built for internal tools, admin panels and CRUD apps. Performance tuning and horizontal scaling are non-goals; SQLite on one machine is the deployment model.
For a million anonymous visitors, use something else.
Not in your model's training data.
EQL, .x.html templates and mingled are custom. A model is
fluent in SQL, Jinja and Tailwind, and will reach for them here.
The trade: training-data priors for a surface small enough to check.
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.
A product that needs a rich client is a different shape.
How that trade is covered
-
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.
-
The verification commands catch DSL mistakes in seconds. Every error message names the fix, not only the fault.
-
Each DSL has one reference file, written as a conversion guide. Every scaffold ships it as
AGENTS.md; the same file is at /llms.txt.
Start
Install and run.
One binary, one SQLite file, no build step.
$ curl -fsSL https://effortless.run/install.sh | sh Downloading effortless-darwin-arm64... Installed: Effortless v0.1.8 $ effortless new crm $ cd crm && effortless serve → http://localhost:3000
The new project contains AGENTS.md, the framework conventions.
The same file is served at effortless.run/llms.txt.