Single binary · zero build · SQLite · MIT

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.

bash
$ effortless new crm
Creating new Effortless project: crm
 Created project structure

$ cd crm && effortless serve
→ http://localhost:3000
The whole thing

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.

routes/todos/todos.lua
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",
}

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.

AI-native, specifically

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.

The feedback loop

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
effortless check — a real diagnostic
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 query language

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

and todos comes back as a JSON string
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

routes/users/users.lua
local users = eql [[
  select users { id, name, todos { title, completed } }
]]
what comes back
[{ "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

string-building, and the % wraps you must remember
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

routes/contacts/contacts.lua
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 })
same query, both states
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

hand-copy the safe fields, then upsert
-- 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

_commands/import_contacts.lua
eql [[
  insert contacts { $row, owner_id: $auth.id }
  on conflict email update
]]
given a row carrying junk
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.

Full grammar, relations, aggregates, transactions →

In the binary

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.

mail

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.

Included

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.

localhost:3000/_admin
The Effortless admin grid showing a contacts table, with the email column's header menu open for editing its type, required and unique flags, and validation rule.
Migrations

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.

_schema/schema.lua
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" },
  },
}
bash
$ 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" }.

Honestly

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. 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. 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. 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.