v0.4.0 Binaries for macOS and Linux

The app you desire, live and yours.

~/Sites/commons
You

Build a site for local meetups: groups post events with a cover image and a number of places, people sign up to save a place, and everyone going gets a reminder email the day before.

Files it wrote. Point at one to see what it makes.

  • $ effortless checkno issues
  • $ effortless testpassed
  • Running at localhost:3000
app/schema/schema.lualines 32–46
32events = {
33  access = { read = "anyone", write = "signed_in" },
34  id = t.pk,
35  group_id = t.ref "groups" { required = true, on_delete = "cascade" },
36  title = t.text { required = true, min = 3, max = 120, label = true },
37  starts_at = t.datetime { required = true },
38  venue = t.text { required = true },
39  address = t.text,
40  capacity = t.integer { required = true, min = 1, default = 40 },
41  cover = t.image { folder = "events" },
42  about = t.text,
43  created_at = t.now,
44
45  indexes = { "starts_at" },
46},
Board game night: its cover, the date and address, 34 going, 6 places left and a Save my place button.
routes/events/[id]/[id].x.htmlThe event page, signed in as a member
Commons on two phones: the list of events with poster covers, and an event page.
routes/index.x.htmlThe events list at phone width
The reminder email for board game night, with the address.
app/mail/reminder.x.htmlThe reminder, sent the day before
app/cron/event_reminders.lualines 1–21
1schedule = "0 9 * * *" -- every day at 09:00 UTC
2help = "Remind everyone going to tomorrow's events"
3
4local eql = require "effortless.eql"
5local mail = require "effortless.mail"
6
7function run()
8  local events = eql [[
9    select events { *, rsvps { users { email, name } } where .status = 'going' }
10    where utc_date(.starts_at) = utc_date(now('+1d'))
11  ]]
12
13  local sent = 0
14  for _, event in ipairs(events) do
15    for _, rsvp in ipairs(event.rsvps) do
16      assert(mail.send {
17        to = rsvp.users.email,
18        template = "reminder",
19        queue = true,
20        data = { name = rsvp.users.name:match("^%S+"), event = event },
21      })
The New event form of Commons: a group select, a title and a calendar open with 15 October picked, in tomato red and Bricolage Grotesque.
app/theme.cssCommons' colors and type, on the library's fields and calendar

Member sites, internal tools or any web app, built by your coding agent with sign-in, email, scheduled jobs and an admin already in place.

Get started

Then ask for the next feature.

Commons is the meetup site from that first prompt. Password reset and invitations came with the project, with their tests. Public sign-up took one sentence, and the waitlist took two.

Waitlist

When an event is full, put new people on a waitlist. If someone cancels, give their place to the first person waiting and email them.

The agent wrote a test that fills an event, cancels a place and checks who gets the email.

  1. Full
  2. A place opens
The language exchange event is full, with 6 on the waitlist and a Join the waitlist button. The email to Lukas: You're going. Someone cancelled, so the first place on the waitlist is his.
See the code app/lib/rsvp.lua
app/lib/rsvp.lualines 17–59 of 61
17-- Cancelling a place gives it to the first person waiting, and emails them.
18-- Their RSVP and their address are another user's, so that read and that
19-- write cross users on purpose.
20function M.cancel(event_id)
21  eql.transaction(function()
22    local mine = eql.one [[
23      select rsvps { id, status }
24      where .event_id = $event_id and .user_id = $auth.id
25    ]]
26    if not mine then
27      return
28    end
29    eql "delete rsvps where .id = $mine.id"
30    if mine.status == "waitlist" then
31      return
32    end
33
34    local first = eql.unscoped([[
35      select rsvps {
36        id,
37        users { email, name },
38        events { id, title, starts_at, venue }
39      }
40      where .event_id = $event_id and .status = 'waitlist'
41      order by .created_at, .id
42      limit 1
43    ]])[1]
44    if not first then
45      return
46    end
47
48    eql.unscoped [[
49      update rsvps set { status: 'going' }
50      where .id = $first.id
51    ]]
52    assert(mail.send {
53      to = first.users.email,
54      template = "promoted",
55      queue = true,
56      data = { name = first.users.name:match("^%S+"), event = first.events },
57    })
58  end)
59end

Every project starts with a component library.

effortless-ui is inside the program: buttons, fields, dialogs, date pickers, tables and the rest, drawn like shadcn/ui until you change them. The classes are Tailwind's, compiled by the same program with no Node.

Your brand

Make it ours: tomato red and Bricolage Grotesque.

The agent wrote the tokens in app/theme.css, and every button, field and picker follows. The board below is the library itself, running on this page, and each theme changes only that file. Open the calendar, pick a time, press a menu.

app/theme.css7 lines
@import "effortless-ui";

@theme static {
  /* --color-brand: light-dark(#2563eb, #60a5fa); */
  /* --radius-field: 0.375rem; */
  /* --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; */
}
app/theme.css26 lines
@import "effortless-ui";

@theme static {
  --color-brand: light-dark(#ef4a2b, #ff6a4d);
  --color-on-brand: light-dark(#ffffff, #140c09);

  --color-page: light-dark(#ffffff, #14120f);
  --color-surface: light-dark(#ffffff, #1d1a16);
  --color-soft: light-dark(#f5f3ef, #26221d);
  --color-ink: light-dark(#17150f, #f4f1ec);
  --color-muted: light-dark(#6f6a62, #a8a199);
  --color-line: light-dark(#e7e4df, #38332d);
  --color-input: light-dark(#e7e4df, #38332d);

  --color-danger: light-dark(#c2392b, #ff7a6b);
  --color-success: light-dark(#1f9d55, #4fd18b);
  --color-waitlist: light-dark(#c98f00, #f0b93a);

  --font-sans: "Bricolage Grotesque", system-ui, -apple-system, "Segoe UI", sans-serif;

  --radius-field: 0.625rem;
  --radius-box: 0.875rem;
  --spacing-field: 2.5rem;

  --shadow-card: 0 0 0 1px var(--color-line), 0 12px 32px -12px light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.6));
}
app/theme.css21 lines
@import "effortless-ui";

@theme static {
  --color-brand: light-dark(#0d7a6f, #60a5fa);
  --color-on-brand: light-dark(#ffffff, #0a0a0a);

  --color-page: light-dark(#f9fafb, #030712);
  --color-surface: light-dark(#ffffff, #111827);
  --color-ink: light-dark(#111827, #f3f4f6);
  --color-muted: light-dark(#4b5563, #9ca3af);
  --color-line: light-dark(#d1d5db, #374151);
  --color-input: light-dark(#d1d5db, #374151);

  --color-danger: light-dark(#b91c1c, #f87171);
  --color-success: light-dark(#166534, #4ade80);

  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;

  --radius-field: 0.5rem;
  --radius-box: 0.5rem;
}
routes/_layout.x.html
/* No token changes. Every colour of the library is
   light-dark(light, dark) and follows the visitor's
   system setting. This attribute forces one: */
<html data-theme="dark">

New event

Draft

People

PersonStatus
AO
Amara Okafor
[email protected]
Going
KS
Kenji Sato
[email protected]
Waitlist
CM
Chloé Martin
[email protected]
Cancelled

Nobody is waiting

The waitlist opens when the last place is taken.

Reminders go out the day before, at 09:00.
Places taken 34 of 40

All 16 components, running

New pages

Let organizers email everyone going to an event.

The agent built the page from the library: ui-field around a text area, a btn, and a toast once the mail is queued. A class that neither Tailwind nor the project's CSS has is an error in effortless check, and the Dockerfile runs the checks before it builds an image.

The message page of Commons: Message everyone going to Board game night, a text area with two lines typed, and a tomato red button, Email 34 people.
See the code routes/events/[id]/message/message.x.html · routes/events/[id]/message/message.lua
routes/events/[id]/message/message.x.html21 lines
1<title>Message attendees · Commons</title>
2<site-bar me={me} />
3<main class="max-w-2xl mx-auto px-6 pt-12 pb-24" @with={event.rsvps.length as going}>
4  <p class="text-sm font-semibold text-brand">{event.group_name}</p>
5  <h1 class="text-3xl font-bold mt-1">Message everyone going to {event.title}</h1>
6  <p class="text-muted mt-2">{going} {going == 1 ? "person gets" : "people get"} this by email, from Commons, with your name.</p>
7  <form
8    method="POST"
9    class="flex flex-col gap-4 mt-6"
10    data-toast="Message sent to {going} {going == 1 ? 'person' : 'people'}"
11  >
12    <ui-field
13      label="Message"
14      hint="Plain text. Say what changed and what people should do."
15      error={errors?.body}
16    >
17      <textarea class="input min-h-32" name="body" required>{old?.body}</textarea>
18    </ui-field>
19    <button class="btn self-start">Email {going} {going == 1 ? "person" : "people"}</button>
20  </form>
21</main>
routes/events/[id]/message/message.lualines 27–52 of 52
27-- Emails everyone going. Their addresses are private, so the read crosses users on purpose.
28function post(request)
29  local body = request.form.body or ""
30  if #body < 10 then
31    return response.invalid({ body = "Write at least a sentence." }, request.form)
32  end
33
34  local event = eql.unscoped([[
35    select events {
36      id,
37      title,
38      .groups.name as group_name,
39      rsvps { users { email } } where .status = 'going'
40    }
41    where .id = $params.id
42  ]])[1]
43  for _, rsvp in ipairs(event.rsvps) do
44    assert(mail.send {
45      to = rsvp.users.email,
46      template = "organizer_message",
47      queue = true,
48      data = { event = event, body = body, from = request.user.name },
49    })
50  end
51  return response.redirect("/events/" .. event.id)
52end

Internal tools your team can trust with real data.

The second sample is Fieldnote's hiring board: a small product company tracks its candidates in a tool built the same way, with roles deciding who sees what.

Roles

Build a hiring board: candidates in columns by stage, their CVs, and notes from each interviewer. Interviewers only see the candidates assigned to them.

Lena, the hiring manager, sees all 13 candidates and what each asked to be paid. Max, an interviewer, sees his 3, without the salaries. Two lines of the schema say so, and the page's code never asks who is signed in. Moving a card to Interview emails the candidate. Tests check both views, and that Max cannot move someone else's candidate.

  1. Hiring manager
  2. Interviewer
Fieldnote's hiring board as the hiring manager: columns for Applied, Phone screen, Interview and Offer, each card with a name, an opening, days in stage, notes, the salary asked for and a move button. The same board as the interviewer Max Ferreira: only his three candidates, with no salaries.
See the code app/schema/schema.lua · routes/index.lua
app/schema/schema.lualines 17–34 of 44
17  -- Each candidate has one interviewer, who reads and moves their own. A
18  -- manager reads and writes every candidate. `salary`, what the candidate
19  -- asked for, is private: only a manager reads it.
20  candidates = {
21    access = { owner = "interviewer_id", write = "manager" },
22    id = t.pk,
23    name = t.text { required = true, label = true },
24    email = t.text { required = true, email = true },
25    opening_id = t.ref "openings" { required = true },
26    interviewer_id = t.ref "users" { required = true },
27    stage = t.enum { "applied", "screen", "interview", "offer", "hired", default = "applied" },
28    stage_since = t.datetime { default = "now()" },
29    salary = t.integer { private = true },
30    cv = t.file { folder = "cvs", slug = true },
31    created_at = t.now,
32
33    indexes = { "stage" },
34  },
routes/index.lualines 7–30 of 62
7-- No line here asks who is signed in. The schema's access gives a manager
8-- every candidate and an interviewer their own, and `*` leaves the private
9-- salary out of an interviewer's rows.
10function get(request)
11  local columns = {}
12  for i, stage in ipairs(stages) do
13    local cards = eql [[
14      select candidates {
15        *,
16        .opening_id.title as opening,
17        .interviewer_id.name as interviewer,
18        count(.notes) as notes
19      }
20      where .stage = $stage.key
21      order by .stage_since
22    ]]
23    for _, card in ipairs(cards) do
24      card.initials = initials(card.interviewer)
25      card.since = format.timeago(card.stage_since)
26    end
27    columns[i] = { stage = stage, next = stages[stage.next], cards = cards }
28  end
29  return { columns = columns, total = eql.count "select candidates" }
30end

An admin for every project.

It is built from your tables, with nothing to set up, and only the addresses you list can open it. Here it is on the hiring board's data. Pick a tab, or let it play.

The candidates table in the admin: 13 rows with name, email, opening, interviewer, stage and stage since, and every table in the sidebar with its row count.
Every table in a grid, with its row count in the sidebar. Search by words, or by a where clause such as .stage = "interview".
Kenji Sato's stage cell open for editing, with a list of the five stages: applied, screen, interview, offer and hired.
Double-click a cell to change it. A column with fixed values offers them as a list.
Elif Yılmaz's record open in a drawer beside the grid: interviewer, stage, stage since, an empty CV field with an Upload button, and related rows.
Open a row to edit every field, upload or replace a file, and add or remove related rows.
Max Ferreira's user record: set a new password, or sign in as this user.
Open a user to set a new password or sign in as them, to see the app as they do. A bar on every page brings you back.
Ask on the hiring data: the question, the read-only query beside it with Run, Copy and CSV, and two candidates, Amara Okafor and Jonas Weber.
Ask in plain words. It writes one read-only query, checked against the schema, and shows it above the rows, with Run, Copy and CSV. Needs an API key from any OpenAI-compatible provider.

Also in the program: file and image uploads, a job queue, AI features in your own pages, and a scrubbed copy of production to work on locally. All of it in the reference.

It checks its own work.

Coding agents write React and Svelte by habit, and the habit slips into a framework they have not seen before. When it does, effortless check stops it with the line, the fix and the docs section to read. The agent fixes it before you open the page, so what you review already works.

Mistakes caught

Show “No events yet.” when there are no upcoming events.

Every project carries an AGENTS.md with the mistakes agents make here, and the reference ships in the program, so it matches the version you run.

  1. First try
  2. check stops it
  3. Fixed
The agent’s first try: line 12 of routes/index.x.html uses a Svelte {#if} block, highlighted in red. effortless check stops it: line 12 is another template language’s block; put @if on the element. The fixed line, with @if on the paragraph, highlighted in green; check finds no issues and all 29 tests pass.

Setting it up for each coding agent

Under the hood.

For whoever reads the code before trusting it.

How it works, with real output

  • Program

    One file. No Node, no build step.

  • Code

    Lua route handlers and .x.html pages, rendered on the server.

  • Data

    SQLite, one file any SQLite tool opens. app/schema/schema.lua is the migration.

  • Security

    Queries take values only as parameters, page text is escaped, posts from other sites are refused and passwords are hashed with bcrypt. Every table says who reads and writes it, and a page needs a sign-in unless its file says public = true.

Yours from the first file.

There is no Effortless account. The app is a folder on your computer and runs on your server.

The commons folder on your computer, with app, routes, tests, AGENTS.md and a Dockerfile; an arrow labelled deploy to a server card for commons with a volume holding app.db and uploads and the daily 09:00 reminders, online; an arrow labelled https to a member's phone showing the event page.

On your computer

A folder of plain files your agent edits. Put it in git, open it in any editor, or hand it to a developer.

On your server

Any host that runs a container. It bills you directly.

For your members

They sign in to your app. Their accounts, RSVPs and emails live in your database, not in someone else's service.

Not needed: an Effortless account, credits or seats, a second backend, a database key in the browser, or an export to leave.

Deploy

New projects come with a Dockerfile. The example here is Railway, which we recommend.

Settings, scaling and errors in production

Deploy this project to Railway: one service from the effortless binary, one volume for the database and uploads, my address as the superadmin, and effortless migrate on each deploy. Tell me the URL when it is up.

  • More traffic

    A bigger machine and a higher WORKERS, with no code change.

  • Uploads

    Railway Buckets are next; Cloudflare R2 works today.

  • Several machines

    Turso support is next. Until then, scale up.

Start with one prompt.

Paste the prompt into your coding agent, or run the commands yourself.

For your coding agent

Install Effortless from effortless.run, create a project and link AGENTS.md to CLAUDE.md. Then build a site for local meetups: groups post events with a cover image and a number of places, people sign up to save a place, and everyone going gets a reminder email the day before.

Or in a terminal:

zsh
$ curl -fsSL https://effortless.run/install.sh | sh
Downloading effortless-darwin-arm64...
Installed: Effortless v0.4.0

$ effortless new commons && cd commons
$ ln -s AGENTS.md CLAUDE.md   # Claude Code reads CLAUDE.md
$ echo SUPERADMINS=[email protected] >> .env   # your account opens /_admin
$ effortless serve
→ http://localhost:3000

The link points Claude Code at AGENTS.md; Codex, Cursor and Copilot read it as is. macOS on Apple silicon and Linux x86_64. Setting up each agent.