v0.1.8 Binaries for macOS and Linux

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

macOS arm64 · Linux x86_64 11–14 MB SHA-256 verified MIT read install.sh

zsh effortless v0.1.8
$ 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.

routes/todos/todos.lua 11 lines
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 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.

localhost:3000/_admin/t/deals
The Effortless admin showing a deals table. Reference columns render as name pills and the stage enum as tags. The sidebar lists every table with its row count.

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.

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

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

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

zsh
$ 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 after removing the where clause
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

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

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.

$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

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

Full grammar, relations, aggregates, transactions

Included

What ships in the binary.

No plugin directory, no package manifest, nothing to install.

routes/

File-based routing with dynamic and catch-all segments, plus root middleware.

EQL

Always-parameterized queries with schema-validated writes, relations across hops, aggregates and computed columns.

.x.html

Liquid-style templates, auto-escaped, with layouts, partials, components and 20+ modifiers.

_schema/schema.lua

Desired-state schema. The reconciler plans and applies the diff; renames keep their data.

auth

bcrypt sessions, roles, guards, 7-day cookies. Tenant tables declare an owner and the checker enforces it.

csrf

Framework-level. A mutating POST without the token is a 403.

/_admin

A grid, a record drawer and a plain-language query box for every table. Allowlisted users only.

t.file · t.image

An upload is a column on the row that owns it. Local disk or Cloudflare R2; deleted with the row.

http · proc

Outbound requests with SSRF protection and subprocesses as argv arrays, never a shell string.

mail

Logs to stderr until a Bento key is set.

ai

Chat, streaming and structured JSON against any OpenAI-compatible endpoint.

realtime

Server-sent events from a change outbox, scoped per subscriber.

_commands/ · _cron/

Named one-shot operations and scheduled jobs, each in its own process.

snapshot · pull

Consistent backups, and a scrubbed copy of production pulled locally in one command.

effortless lsp

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.

_schema/schema.lua one rename, one new column
  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" },
  },
zsh
$ 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

  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 verification commands catch DSL mistakes in seconds. Every error message names the fix, not only the fault.

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

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