The whole web stack in one binary.
HTTP server, SQLite, a query language, server-rendered pages, Svelte-style islands, auth, jobs and an admin, in one executable. You, or your agent, write Lua and HTML. A command, not a user, reports what is wrong.
$ 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 app/schema/schema.lua + Creating table todos + Creating table users + Creating table jobs + Creating table sessions + Creating table user_tokens … ✓ Loaded 7 routes
One process serves the page, runs the query and checks the code.
Effortless is for the software a team runs its work on: internal tools, admin panels, back offices, CRUD apps with a few screens that need to feel live. There is no toolchain to install and nothing to build; an edit shows on the next request.
Pages render on the server.
A route is a folder. Its Lua handler returns data, its .x.html view
renders it, and a form posts back to the same route. Values are escaped unless the view
writes {@html}.
Islands where a page needs to be live.
A component with a <script> runs in the browser, written like a
Svelte component. The server compiles it; /_inlay.js, 9.6 KB
gzipped, mounts it. No bundler, no node_modules.
A database you cannot inject.
SQLite, queried in EQL, which binds every value and has no raw SQL. The schema file is the migration, and every insert and update is validated against it.
Checked without a browser.
check, request and
test run in-process and print JSON on request. An agent runs them
after every edit and reads the answer.
A server page with an island in it.
A todo list in five files. The page renders on the server with the signed-in user's rows;
the list is an island that adds, toggles and deletes through inlay.post
without reloading. Added to a fresh effortless new project, it passes
check and test.
local t = require "effortless.types" return { -- users, sessions, jobs and user_tokens come with the scaffold todos = { id = t.pk, user_id = t.ref "users" { required = true, owner = true }, 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" local response = require "effortless.response" guard = auth.check function get(request) return { todos = eql "select todos { id, title, completed } where .user_id = $auth.id order by .id" } end actions = { add = function(request) local todo = eql "insert todos { title: $form.title, user_id: $auth.id } returning { id, title, completed }" return response.json(todo) end, toggle = function(request) eql "update todos set { completed: not .completed } where .id = $form.id and .user_id = $auth.id" return response.empty(204) end, remove = function(request) eql "delete todos where .id = $form.id and .user_id = $auth.id" return response.empty(204) end, }
<title>Todos</title> <main class="max-w:560 mx:auto py:48 px:16"> <h1 class="f:28 fw:600 mb:24">Todos</h1> <todo-list todos={todos} /> </main>
<script> export let todos let title = '' let filter = 'all' let error = '' function visible() { if (filter === 'open') return todos.filter((todo) => !todo.completed) if (filter === 'done') return todos.filter((todo) => todo.completed) return todos } function remaining() { return todos.filter((todo) => !todo.completed).length } function pick(name) { filter = name } async function add() { const result = await inlay.post('/todos', { action: 'add', title }) if (result.ok) { todos = todos.concat([result.data]) title = '' error = '' } else { error = result.errors?.title || result.error } } async function toggle(todo) { todo.completed = !todo.completed const result = await inlay.post('/todos', { action: 'toggle', id: todo.id }) if (!result.ok) todo.completed = !todo.completed } async function remove(todo) { todos = todos.filter((each) => each.id !== todo.id) const result = await inlay.post('/todos', { action: 'remove', id: todo.id }) if (!result.ok) todos = todos.concat([todo]) } </script> <section class="bg:white b:#e5e7eb|1 r:12 overflow:hidden"> <form class="flex gap:8 p:12 bb:#e5e7eb|1" @submit.prevent={add}> <input bind:value={title} aria-label="New todo" placeholder="What needs doing?" class="flex:1 px:12 py:8 b:#d1d5db|1 r:8"> <button class="px:16 py:8 bg:#111827 c:white r:8">Add</button> </form> <p role="alert" class="px:16 pt:12 f:14 c:#b91c1c" @if={error}>{error}</p> <ul> <li class="flex:between|center gap:12 px:16 py:10 bb:#f3f4f6|1" @each={visible() as todo (todo.id)}> <label class="flex:center gap:10 pointer"> <input type="checkbox" checked={todo.completed} @change={toggle(todo)}> <span class:line-through={todo.completed} style:opacity={todo.completed ? 0.5 : 1}>{todo.title}</span> </label> <button type="button" aria-label="Delete {todo.title}" class="c:#9ca3af c:#b91c1c:hover" @click={remove(todo)}>✕</button> </li> </ul> <p class="px:16 py:12 f:14 c:#6b7280" @if={!visible().length}>Nothing here.</p> <footer class="flex:between|center px:16 py:10 f:13 c:#6b7280 bt:#e5e7eb|1"> <span>{remaining()} left</span> <span class="flex gap:12"> <button type="button" class:fw:600={filter === 'all'} @click={pick('all')}>All</button> <button type="button" class:fw:600={filter === 'open'} @click={pick('open')}>Open</button> <button type="button" class:fw:600={filter === 'done'} @click={pick('done')}>Done</button> </span> </footer> </section>
local function post(user, form) return app.post("/todos", { as = user.id, form = form, headers = { Accept = "application/json" } }) end test("the island adds, toggles and removes a todo", function(t) local ada = t.user("[email protected]") local added = post(ada, { action = "add", title = "Ship the website" }) t.eq(added.status, 200) t.eq(added.json.title, "Ship the website") t.eq(post(ada, { action = "toggle", id = added.json.id }).status, 204) t.eq(app.get("/todos", { as = ada.id }).data.todos[1].completed, true) t.eq(post(ada, { action = "remove", id = added.json.id }).status, 204) t.eq(#app.get("/todos", { as = ada.id }).data.todos, 0) end) test("an empty title comes back as a field error", function(t) local res = post(t.user("[email protected]"), { action = "add", title = "" }) t.eq(res.status, 422) t.truthy(res.json.errors.title) end) test("one user cannot toggle another's todo", function(t) local ada = t.user("[email protected]") local todo = post(ada, { action = "add", title = "Mine" }).json post(t.user("[email protected]"), { action = "toggle", id = todo.id }) t.eq(app.get("/todos", { as = ada.id }).data.todos[1].completed, false) end)
Scoped to the owner
owner = true makes check require
.user_id = $auth.id on every query on todos. A
request that runs one without it is refused.
Validation from the schema
An empty title fails required = true. The island gets a 422 with
errors.title and shows it. The handler checks nothing.
CSRF without code
inlay.post sends the token from the cookie. A write without it, or
one a browser marks as from another site, is a 403.
Props from the server
todos={todos} hands the handler's rows to the island, written into
the page as JSON, so the list is there before any script runs.
Optimistic writes
Toggle and delete change the list first and put it back when the request fails. Assigning to
a let is the whole state API.
Tested without a browser
The tests call the same actions the island calls, with the same
Accept header, each against a fresh database.
Most pages need no island. The same list with plain forms and no JavaScript is 11 lines of Lua: the CRUD recipe.
Svelte's component model, compiled by the server.
If you have written Svelte 4 you have written most of an Inlay component: one file,
let for state, export let for props,
bind:, use: and event modifiers. Control flow and
events are attributes on the element instead of blocks.
Svelte and Inlay
| Svelte 4 | Inlay |
|---|---|
| let count = 0 · export let name | the same |
| {#if a}<p>…</p>{:else}<p>…</p>{/if} | <p @if={a}>…</p><p @else>…</p> |
| {#each items as item (item.id)}<li>…</li>{/each} | <li @each={items as item (item.id)}>…</li> |
| on:click|preventDefault={save} | @click.prevent={save} |
| $: total = a + b | function total() { return a + b } |
| <svelte:window on:keydown={close} /> | @keydown.window={close} |
| import { format } from './dates.js' | the same, from a .js file in the project |
One grammar on both sides
A component without a script renders on the server where a view uses it. Add a
<script> and it runs in the browser. Its attributes are its props
either way.
Navigation without reloads
/_inlay.js fetches same-origin links and form posts and swaps the body,
keeping scroll and focus. State in a shared module carries across pages.
Checked like the server code
check reports a name the markup reads that the script never declares,
an event modifier the compiler does not know, and a dialog trigger whose id nothing renders.
Icons from your own SVGs
<icon name="pin" /> writes app/icons/pin.svg
inline, so it takes currentColor and the classes around it. Scripts and
outside references are stripped from the file.
Dates in the page's language
formatDate('d MMMM yyyy') names the month in the
lang of the page's <html>.
formatRange turns two dates into "3–4 October".
Assets cached for a year
?v={asset_version} on a file in public/ is a hash
of the folder. A request that carries the current hash is served
immutable.
A model will guess. The guess fails loudly.
EQL, the markup and mingled are not in any model's training data, so an agent writes SQL, Jinja and Tailwind here first. Effortless is built so a wrong guess is an error naming the fix and the section to read, not a nil that surfaces in production.
An 83-line AGENTS.md.
effortless new writes a short guide: the wrong guesses that still
parse, which docs topic each task needs, and the commands that check the work. The 102 KiB
reference stays in the binary and matches the installed version:
effortless docs eql, effortless docs --search sync_rows.
Errors that name the section.
A function a framework module does not have raises with the closest real name. Every
check run that finds something ends with the docs topics to read.
Safety a reviewer does not re-check.
There is no raw SQL, so injection cannot be written. Output is escaped unless the view writes
{@html}. CSRF is checked on every write, a runaway Lua loop ends at
LUA_TIMEOUT, and an unscoped query on an owner table is refused.
Nothing to rebuild.
An edit applies on the next request. The loop an agent runs is edit, check,
request, test, with no dev server to restart and
no bundle to wait on.
$ effortless eval 'return eql.cout "select todos"' Error: (eval):1: effortless.eql has no field 'cout' (did you mean 'count'?); see effortless docs eql
$ effortless check routes/todos/todos.lua:8: [tenancy] select on "todos" should filter .user_id to $auth (or mark the call `-- eql: unscoped`) 1 issue found Read before fixing: effortless docs multi-tenant
Four commands that replace the browser.
Each runs once and exits, and each takes --json. For a browser agent,
effortless login-link prints a URL that signs it in, so it never types a
password.
01
effortless check
Static analysis over the whole project: Lua syntax, schema declarations, EQL literals against the schema, markup and islands, each view against what its handler returns, owner scoping.
$ effortless check ✓ No issues found $ effortless check --json {"ok":true,"errors":[]}
02
effortless eval
One Lua snippet in the app's context, with the database attached. Prints JSON.
--as runs it as a user.
$ effortless eval 'return eql.count "select todos"' 2
03
effortless request
A request through the router, in-process, with no server running.
--data prints the data the view gets instead of the HTML;
--as signs the request in.
$ effortless request GET /todos --data --as [email protected] HTTP 200 X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN Content-Security-Policy: frame-ancestors 'self' Referrer-Policy: strict-origin-when-cross-origin X-Inlay-Version: 0ef47a56847dccaf {"path":"/todos","method":"GET","base_url":"http://localhost:3000","asset_version":"e3b0c44298fc","user":{"id":1,"email":"[email protected]","name":"Ada","role":"user","created_at":"2026-10-01 14:03:41"},"logged_in":true,"todos":[{"completed":false,"id":1,"title":"Ship the website"},{"completed":true,"id":2,"title":"Write the docs"}]}
04
effortless test
The project's tests/*.lua, each test against a fresh temporary database.
The scaffold ships tests for password reset and invitations; these three are the example's.
$ effortless test ✓ a reset link sets a new password once (tests/auth_test.lua) ✓ an unknown address gives nothing away (tests/auth_test.lua) ✓ a reset holds to the password rule (tests/auth_test.lua) ✓ an invitation creates the account and its first password (tests/auth_test.lua) ✓ the island adds, toggles and removes a todo (tests/todos_test.lua) ✓ an empty title comes back as a field error (tests/todos_test.lua) ✓ one user cannot toggle another's todo (tests/todos_test.lua) 7 passed, 0 failed
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. Light and dark. Nothing to configure.
Inline editing
Cells save on blur. Deletes are deferred with an Undo.
Record drawer
Open a row, edit it, and add or remove related records from the same panel. The framework's own tables sit under More.
Ask
A plain-language question becomes one validated, read-only EQL statement, shown and editable. Save it, export CSV, or click a value through to its row.
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 on v0.2.2 with a three-table CRM schema and seed data.
The Ask reply came from a stub model endpoint; the validation, the query and the rows are real.
Simple queries read like SQL. The rest is shorter.
A plain select is SQL with a dot before the column name. The differences show up 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 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
]], { owner_id, safe.name, safe.email })
With EQL
eql [[ insert contacts { $row, owner_id: $owner_id } on conflict email update ]]
row = { name, email, role = "admin", action = "add" } ✓ role and action dropped · owner_id set from $owner_id ✓ second import updates the row, does not duplicate it
Every write is validated against the schema before it runs, and every value is a bound parameter. Relation paths filter across up to six hops. There is no raw SQL escape hatch.
The schema file is the migration.
Declare the desired state. The reconciler diffs it against the database and applies the
difference: when the file changes 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.
What ships in the binary.
No plugin directory, no package manifest, nothing else to install.
File-based routing with dynamic and catch-all segments. A folder's _layout.lua guards and loads data for everything below it.
Parameterized queries with schema-validated writes, relation paths, computed columns, transactions, and eql.page for a paginated list in one call.
One grammar for server pages and browser components, modelled on Svelte. Escaped by default; malformed markup is an error with a line number.
Desired-state schema. The reconciler plans and applies the diff; a rename keeps its data.
bcrypt passwords, sessions, roles and guards. The scaffold ships sign-in, password reset and invitations with tests; sign-in is rate limited.
Forms and inlay.post carry the token. A write from another origin is a 403. Every response sends nosniff, frame and referrer headers.
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, on disk or Cloudflare R2, with image dimensions on request. Deleted with the row, after the commit.
Background work queued into an ordinary table you can query and watch in the admin. Scheduled jobs and named CLI operations beside it.
Templates in the view grammar. Logs to stderr until a Resend or Bento key is set; a catch-all address for staging; delivery through the job queue.
Chat, streaming, tool calls and structured JSON against any OpenAI-compatible endpoint. Two variables to turn on.
Server-sent events from a change outbox written in the same transaction, filtered per subscriber.
Consistent backups, and a copy of production pulled locally with every secret column replaced, from the schema.
Runs a cloudflared tunnel against the port it just bound, so testing on a phone is one command.
A language server for the DSLs, a formatter that only moves whitespace, and the reference printed by section or by search.
What it is not for.
Traffic that needs more than one machine.
The database is a SQLite file on one volume. WORKERS runs one process
per CPU on that machine: reads run in parallel, writes take turns, and a realtime event reaches
browsers on every worker within a quarter second.
That fits the tools a team runs its work on. A public site with millions of visitors wants something else.
An app that lives in the browser.
Pages render on the server and islands make parts of them live. An application that is mostly client state over an API, offline-first or canvas-heavy, fits a JavaScript framework better.
A stable API, yet.
Before 1.0, APIs change between releases, and the release notes name each change. The source opens at 1.0, once the framework has run in production and been patched against what that turns up.
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.2.4 $ effortless new crm $ cd crm && effortless serve → http://localhost:3000
The project contains AGENTS.md, a short guide for coding agents. Codex,
Cursor and Copilot read it as is; Claude Code needs ln -s AGENTS.md CLAUDE.md.
Setting up each agent.