v0.3.1 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 78–91
78events = {
79  id = t.pk,
80  group_id = t.ref "groups" { required = true, on_delete = "cascade" },
81  title = t.text { required = true, min = 3, max = 120, label = true },
82  starts_at = t.datetime { required = true },
83  venue = t.text { required = true },
84  address = t.text,
85  capacity = t.integer { required = true, min = 1, default = 40 },
86  cover = t.image { folder = "events" },
87  about = t.text,
88  created_at = t.now,
89
90  indexes = { "group_id", "starts_at" },
91},
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–13
1schedule = "0 9 * * *" -- every day at 09:00 UTC
2help = "Remind everyone going to tomorrow's events"
3
4function run()
5  local eql = require "effortless.eql"
6  local format = require "effortless.format"
7  local mail = require "effortless.mail"
8
9  local events = eql [[
10    select events { id, title, starts_at, venue, address,
11      rsvps { users { email, name } } where .status = "going" }
12    where date(.starts_at) = date("now", "+1 day")
13  ]]
The Commons styleguide: tomato red, Bricolage Grotesque, buttons, fields and the event card.
public/design.jsThe styleguide, in Commons' colors and type

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

Member sites, with accounts from the first prompt.

Commons is a site for local meetups: people join, save a place at an event, and hear about it by email. Sign-in, password reset and invitations came with the new project, with their tests; public sign-up took one sentence.

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 28–56 of 58
28function M.cancel(event_id)
29  local mine = eql.one("select rsvps { id, status } where .event_id = $id", { id = event_id })
30  if not mine then return end
31  eql("delete rsvps where .id = $id", { id = mine.id })
32  if mine.status ~= "going" then return end
33
34  local next = eql.unscoped([[
35    select rsvps { id, .user_id.email as email, .user_id.name as name,
36      .event_id.title as title, .event_id.starts_at as starts_at, .event_id.venue as venue }
37    where .event_id = $id and .status = "waitlist"
38    order by .created_at, .id
39    limit 1
40  ]], { id = event_id })[1]
41  if not next then return end
42
43  eql.unscoped([[update rsvps set { status: "going" } where .id = $id]], { id = next.id })
44  assert(mail.send {
45    to = next.email,
46    template = "promoted",
47    queue = true,
48    data = {
49      name = next.name:match("^%S+"),
50      title = next.title,
51      when = format.date(next.starts_at, "%A %d %B, %H:%M"),
52      venue = next.venue,
53      event_id = event_id,
54    },
55  })
56end

Reminder emails

The day before each event, email everyone going a reminder with the address.

A scheduled job and an email template with a preview in the browser. For tomorrow's board game night it queued 34 emails, one per person going.

A phone with two Commons notifications and the reminder email: See you tomorrow, Sofia, with the date, the address and a See the event button.
See the code app/cron/event_reminders.lua
app/cron/event_reminders.lua35 lines
1schedule = "0 9 * * *" -- every day at 09:00 UTC
2help = "Remind everyone going to tomorrow's events"
3
4function run()
5  local eql = require "effortless.eql"
6  local format = require "effortless.format"
7  local mail = require "effortless.mail"
8
9  local events = eql [[
10    select events { id, title, starts_at, venue, address,
11      rsvps { users { email, name } } where .status = "going" }
12    where date(.starts_at) = date("now", "+1 day")
13  ]]
14
15  local sent = 0
16  for _, event in ipairs(events) do
17    for _, rsvp in ipairs(event.rsvps) do
18      assert(mail.send {
19        to = rsvp.users.email,
20        template = "reminder",
21        queue = true,
22        data = {
23          name = (rsvp.users.name or ""):match("^%S+") or "there",
24          title = event.title,
25          when = format.date(event.starts_at, "%A %d %B, %H:%M"),
26          venue = event.venue,
27          address = event.address,
28          event_id = event.id,
29        },
30      })
31      sent = sent + 1
32    end
33  end
34  return { sent = sent }
35end

Every project starts with a design system.

public/design.js holds the colors, type, spacing and dark mode. Buttons, fields, notices and cards are components in app/components, shown together at /styleguide. When you ask for a new feature, the agent builds it from those parts, and a test fails if it writes its own.

Your brand

Make it ours: tomato red and Bricolage Grotesque. Add the nav and the event card to the styleguide.

The agent changed the tokens in public/design.js and added two parts next to the ones every project starts with. Change a token and every page follows: the second frame is the same styleguide after changing brand.

  1. #ef4a2b
  2. #1d3fbf
public/design.js and the list of components beside the Commons styleguide: ui-button in four kinds, ui-field with an error, ui-notice, the site-bar and event cards, in tomato red. The same styleguide after changing brand to blue: the primary button, the field error and the logo are blue.
See the code public/design.js · app/components/ui-button.x.html
public/design.jslines 64–77 of 97
64Mingled.define("btn", {
65  base: "px:xl py:md r:md f:md fw:700 pointer transition:colors",
66  variants: {
67    kind: {
68      primary: "bg:brand c:on-brand bg:brand-strong:hover",
69      secondary: "bg:surface c:text b:border|1 bg:paper:hover",
70      dark: "bg:text c:page",
71      quiet: "px:0 py:xs bg:transparent c:muted underline fw:500",
72      danger: "bg:danger c:on-brand",
73    },
74    wide: { true: "w:full" },
75  },
76  defaultVariants: { kind: "primary" },
77})
app/components/ui-button.x.html3 lines
1<!-- kind: primary (default), secondary or danger; the btn recipe in
2     public/design.js. type defaults to submit. wide stretches it. -->
3<button type={type || "submit"} name={name} value={value} class="btn {'btn:' + (kind || 'primary')} {wide ? 'btn:wide' : ''}"><slot /></button>

New pages

Let organizers email everyone going to an event.

The agent built the page from parts the styleguide already had: the nav, a field and a button. Every new project tells the agent to in its AGENTS.md, and ships a test that fails on a button or field written by hand, naming the file, the line and the part to use. effortless check also flags a color or size that is not in the theme.

  1. The new page
  2. If a page skips the parts
The new message page, with its parts labelled: site-bar at the top, ui-field for the message and ui-button for Email 34 people. The rule effortless new writes into AGENTS.md, above two outputs: effortless test naming message.x.html line 10 as a button written by hand, and effortless check naming a color that is not a theme token.
See the code routes/events/[id]/message/message.x.html · routes/events/[id]/message/message.lua
routes/events/[id]/message/message.x.html12 lines
1<title>Message attendees · Commons</title>
2<site-bar name={me.name} initials={me.initials} organizer={me.organizer} />
3<main class="max-w:page mx:auto px:xl pt:3xl pb:5xl">
4  <p class="f:sm fw:600 c:brand">{event.group_name}</p>
5  <h1 class="f:2xl fw:700 mt:xs">Message everyone going to {event.title}</h1>
6  <p class="c:muted mt:sm">{going} {going == 1 ? "person gets" : "people get"} this by email, from Commons, with your name.</p>
7  <div class="mt:lg" @if={sent}><ui-notice kind="success">Sent to {sent} people.</ui-notice></div>
8  <form method="POST" class="flex-col:start|stretch gap:lg mt:xl">
9    <ui-field label="Message" name="body" type="textarea" value={old?.body} error={errors?.body} hint="Plain text. Say what changed and what people should do." />
10    <div><ui-button>Email {going} {going == 1 ? "person" : "people"}</ui-button></div>
11  </form>
12</main>
routes/events/[id]/message/message.lualines 24–44 of 44
24-- Emails everyone going. The read crosses users on purpose: an organizer writes to all of them.
25function post(request)
26  local event = event_for_organizer()
27  if not event then return response.error(403, "Only the group's organizers can message attendees") end
28  local body = request.form.body or ""
29  if #body < 10 then
30    return response.invalid({ body = "Write at least a sentence." }, request.form)
31  end
32  local people = eql.unscoped([[
33    select rsvps { .user_id.email as email } where .event_id = $id and .status = "going"
34  ]], { id = event.id })
35  for _, person in ipairs(people) do
36    assert(mail.send {
37      to = person.email,
38      template = "organizer_message",
39      queue = true,
40      data = { title = event.title, group_name = event.group_name, body = body, from = request.user.name, event_id = event.id },
41    })
42  end
43  return response.redirect("/events/" .. event.id .. "/message?sent=" .. #people)
44end

Phone layout

Check every page at phone width and fix what wraps.

The agent opens each page at phone width and fixes the layout. To try it on your own phone, effortless serve --public serves it through a Cloudflare tunnel.

Commons on two phones: the list of events with poster covers, and an event page saying You're going, with Cancel my place.

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. Max, an interviewer, sees his 3. 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 and a move button. The same board as the interviewer Max Ferreira: only his three candidates.
See the code app/schema/schema.lua · routes/index.lua
app/schema/schema.lualines 67–79 of 88
67  candidates = {
68    id = t.pk,
69    name = t.text { required = true, label = true },
70    email = t.text { required = true, email = true },
71    opening_id = t.ref "openings" { required = true },
72    interviewer_id = t.ref "users" { required = true, owner = true },
73    stage = t.enum { "applied", "screen", "interview", "offer", "hired", default = "applied" },
74    stage_since = t.datetime,
75    cv = t.file { folder = "cvs", slug = true },
76    created_at = t.now,
77
78    indexes = { "interviewer_id", "stage" },
79  },
routes/index.lualines 17–25 of 64
17function get(request)
18  local manager = auth.has_role("admin")
19  -- Hiring managers see every candidate on purpose; an interviewer's reads stay scoped to them.
20  local read = manager and eql.unscoped or eql
21  local rows = read([[
22    select candidates { id, name, stage, stage_since, cv, .opening_id.title as opening,
23      .interviewer_id.name as interviewer, count(.notes) as notes }
24    order by .stage_since
25  ]])

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, Svelte and Tailwind 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.

The agent wrote the line the way Svelte would, and fixed it on its next step. 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 10 of routes/index.x.html uses a Svelte {#if} block, highlighted in red. effortless check stops it: line 10 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 12 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: 12 MB on macOS, 16 MB on Linux. 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. A table with an owner returns only the signed-in user's rows.

  • Checks

    effortless check and effortless test after every edit, and the reference for your version at effortless docs.

Yours from the first file.

There is no Effortless account. The app is a folder on your computer and runs on your server, with the database, sign-in, email and admin inside it.

The commons folder on your computer, with app, routes, public/design.js, tests and AGENTS.md; 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

One service and one volume for the database and uploads. Your host 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.

Online on your server.

New projects come with a Dockerfile, so any host that runs a container works. 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

    On the volume. Railway Buckets are next; Cloudflare R2 works today.

  • Several machines

    Turso support is next. Until then, scale up.

Start with one prompt.

Your members sign in to an app you designed, running on your server. Paste the prompt into your coding agent, or run the commands yourself.

  1. 1

    Paste the prompt

    Your agent installs Effortless and builds the first version.

  2. 2

    Ask for changes

    In plain sentences; it checks its own work after each one.

  3. 3

    Put it online

    One more sentence deploys it to your server.

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

$ 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 project contains AGENTS.md, a short guide for coding agents. The link points Claude Code at it; Codex, Cursor and Copilot read it as is. macOS on Apple silicon and Linux x86_64. Setting up each agent.