# Getting Started Elements is the full stack for building web apps; no other tools are required. It includes the build tooling, package installer, test runner, job runner, and hot program runner, plus an application framework (`@elements/app`) with its own reactive, server-rendered html language. Deploy to any Ubuntu Linux box over SSH; Elements handles load balancing and SSL automatically. This is the getting-started guide. It covers the whole toolkit and app framework end to end and is enough for a human or an agent to become productive. Dive into any topic with `elements man ` (for example `elements man build`). Fuzzy-search everything with `elements man -s `. **Scaffold with `elements create`; don't hand-author files a generator can make.** Use `elements create page/template/migration/job/email` to add resources: the generators write the correct files and wire imports that resolve, and page scaffolds surface the exact route line to register, so the tool owns the output. Edit the generated files afterward; hand-author only when no generator fits. Commands under [Scaffolding](#scaffolding). ## The Build Loop The basic workflow, especially for an agent, is: 1. Edit and save a file. 2. Run `elements build -json` to check for errors. 3. Correct each error and re-run `elements build -json` until it comes back empty. A long-running project server runs in the background per project (lazy-started, idle-shuts-down). Everything goes through it: installer, build, test, jobs, deploy. `elements` commands connect as clients and all see the same state. It rebuilds continuously on file changes, so `elements build -json` just queries the current state and returns instantly. Save and check the build often. `-json` is the structured-output flag across the `elements` command surface (`build`, `test`, `deploy`, …): it writes JSON to stdout and exits. Leave it off for plain text. The command then runs as a TUI that adapts to pipes and agent shells automatically. A build does everything required to get the app release-ready: install, parse, compile, resolve, emit, test, release. There is no separate install, migrate, or test step. Individual commands still exist for specific actions (`elements install @elements/app`, `elements test -json`). Every `elements` command supports `-h`/`--help` with a self-contained help screen. Read the whole thing. See `elements man cli` for the command surface. When you change UI, verify it renders. A green build means the code compiles, not that the page looks right. If the app is running (the user starts it with `elements start`, serving http://localhost:4000) and a Chromium-based browser is installed (Chrome, Edge, or Chromium), screenshot the route headless and read the image back: ```bash # macOS Chrome; Windows: chrome.exe/msedge.exe, Linux: google-chrome, same flags. "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ --headless=new --disable-gpu --hide-scrollbars --window-size=1200,900 \ --screenshot=/tmp/page.png "http://localhost:4000/your-route" ``` Open the PNG and check layout, spacing, and that design-system classes render right. Do this whenever you touch a template or stylesheet. ## Create and Run an App ```bash elements create myapp cd myapp code . # open the project in your editor elements create page pricing # scaffold a page; prints the route line to add elements start ``` In development, `elements start` runs the user's server and opens the browser to the app at http://localhost:4000, switching to an error view if there are any build errors. The user runs `elements start`; agents should not start the application. They query `elements build -json` instead. In VS Code or Cursor, the Elements extension adds an "Elements: New Project" command to the command palette. It scaffolds the project, opens it in the current window, and drops you into a terminal ready to run `elements start`. There is nothing else to install. Elements bundles Postgres and manages a local database cluster for you (see [Database and Migrations](#database-and-migrations) below), so a freshly created app builds and runs with no external services. ## Upgrading Elements Run `elements upgrade` weekly. The tooling always produces a runnable app; runtime packages like `@elements/app` follow semver, so upgrade them more conservatively as time permits. Release notes: [The Feed](https://elements.dev/feed). ## Project Layout The first file you likely want to edit is `app/pages/home/template.html`, the page the user has open in their browser. ``` .elements/ # private build/server state; do not read, write, or search here AGENTS.md # agent instructions (this getting-started guide) index.ts # app entry: creates the App, registers routes, starts the server worker.ts # services setup for job and test worker processes config.jsoc # dependencies, the import map, and app config package.lock # resolved dependency lockfile (generated; don't edit by hand) config/ env/ development.env # committed; dev-safe values production.env # gitignored; production secrets app/ pages/ home/ index.ts # route handler template.html # page markup and inline TypeScript style.css # page styles test.ts # page tests services.ts # per-page rpc + shared interfaces (add by hand as the page grows) errors/ not-found/ # 404 page unhandled/ # 500 page shared/ templates/ # reusable templates (layout, etc.) services/ # cross-page rpc, LiveTables, channels styles/ page.css # site-wide page baseline (imports @elements/style) email.css # site-wide email baseline vars.css # design-token overrides assets/ # favicons, images jobs/ # background jobs emails/ # email templates migrations/ # generated migration files types/ session.d.ts # SessionData augmentation (types session.get()) node_modules/ ``` There is no root `package.json` or `tsconfig.json`. Dependencies, the import map, and TypeScript options all live in `config.jsoc`, and the resolved dependency set is written to `package.lock` (don't edit it by hand). Some folders (`app/jobs/`, `app/emails/`, `app/shared/services/`, `app/migrations/`) are created as you need them. Hidden folders at the project root (anything starting with `.`, like `.elements/`, `.git/`, `.claude/`) are not part of your source tree. Don't grep, glob, or otherwise search inside them. Ripgrep and editor search already skip hidden folders by default; if you shell out to `grep -r` or `find`, pass `--exclude-dir=.elements` (grep) or `-not -path '*/.elements/*'` (find). ### The Entry Point `index.ts` at the project root constructs the `App`, registers routes, and starts the server: ```typescript import { App } from "@elements/app"; import config from "#config"; import home from "#app/pages/home"; const app = new App(); app.route("/", home); app.start(config); ``` `worker.ts` brings up the shared services (the database pool, email) that job and test worker processes need, so `sql()`, `tx()`, and `email()` work inside jobs and tests the same way they do in an `@rpc` handler. Delete it if your app has no jobs and no database-touching tests. ## At a Glance A small real-time comment app. Most of Elements in one place: html templates, a route, a migration, and a `LiveTable`. **index.ts** ```typescript import { App } from "@elements/app"; import config from "#config"; import home from "#app/pages/home"; const app = new App(); app.route("/", home); app.start(config); ``` **app/pages/home/models.ts** ```typescript export interface Comment { id: string; text: string; selected?: boolean; } ``` **app/migrations/20260509120000-add-comments-table.migration.sql** Generated by `elements create migration "add comments table" -tables=comments`. The `-tables` flag scaffolds the canonical baseline (`id`, `createdAt`, `updatedAt`, and the `touchUpdatedAt` trigger) for each named table. Pass `-tables=comments,users` to scaffold several at once. ```sql create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create table comments ( id uuid primary key default uuidGenerateV7(), text text not null, createdAt timestamptz not null default now(), updatedAt timestamptz not null default now() ); create trigger commentsTouchUpdatedAt before update on comments for each row execute function touchUpdatedAt(); ``` `id` is UUIDv7: time-sortable, so newer rows sort after older ones without a separate timestamp index. Columns are camelCase in the migration. Elements converts to snake_case at the database boundary and back to camelCase on results. Save the file and the project server applies the migration to the development database. Edit the file (add a column, an index, a constraint) and the project server rolls back and re-applies. Once a migration has run on staging or production it's frozen and never runs again. Agents don't run `elements db migrate` or touch the database directly. **app/pages/home/index.ts** ```typescript import { Request, Response, LiveTable } from "@elements/app"; import { Comment } from "./models"; import html from "./template"; // LiveTable for the `comments` table, inferred from the variable name. // Wires up select/insert/update/delete rpc and optimistic browser-side // mutations. `realtime: true` opts into the Postgres NOTIFY/LISTEN // broadcast so every watching browser sees new rows live; leave it off // for a snapshot read. const comments = new LiveTable({ realtime: true, }); export default function route(req: Request, res: Response) { return new html({ comments, mode: req.params.mode ?? "light", }); } ``` **app/pages/home/template.html** ```html import { Layout } from "#app/shared/templates/layout"; import { Comment } from "./models"; import { LiveTable } from "@elements/app"; import "./style.css"; // event handlers function onAddComment(form: { text: string }, comments: LiveTable) { comments.insert({ text: form.text }, () => form.text = ""); } // templates , mode: string = "dark") class="home">
  • comment.selected = true} onblur={() => comment.selected = false}> {comment.text}
  • , private form: { text: string } = { text: "" })>
    onAddComment(form, comments)}>
    ``` This one example covers: `App` and `app.route()`, a migration with `id`/camelCase/trigger, a page route handler returning `new html({...})`, a `LiveTable` with select/insert/update/delete and realtime, template parameters with `private` and defaults, sub-templates and ``, `e:for` (keyed by `id`), event handlers, two-way form binding with `value=`, reactive class arrays, and lifecycle-style reactivity via mutating `comment.selected`. ## More Patterns Things the example above doesn't show. Each subsection points to its topic for the full story. ### Rpc Use `/** @rpc */` to make a function callable from the browser. The compiler rewrites browser call sites into network requests and removes the body from the browser bundle. Call them like normal functions: no `await`, no `async`, no fetch. Arguments and return types are checked end-to-end. ```typescript // app/pages/home/services.ts import { sql, ValidationError } from "@elements/app"; import { Comment } from "./models"; /** @rpc */ export function searchComments(query: string): Comment[] { if (query.length < 2) throw new ValidationError("query too short"); return sql(` select * from comments where text ilike ${"%" + query + "%"} order by createdAt desc limit 20 `).all(); } ``` `@rpc` is the boundary between browser-reachable and server-only code. Once execution crosses into an rpc, anywhere downstream (the rpc body, helpers it calls, helpers those call) can call `sql()`, `tx()`, `session.login()` freely. Browser-reachable code that calls them is a compile error pointing at the call site. Rpc go in `services.ts` next to a page, or in `app/shared/services/.ts` for cross-page use. Never in a page's `index.ts`. Full topic: `elements man rpc`. ### Sql and Transactions ```typescript import { sql, tx } from "@elements/app"; let user = sql(`select * from users where id = ${id}`).first(); let users = sql(`select * from users where active = ${true}`); for (let u of users) { /* ... */ } tx(() => { let u = sql(`insert into users (name) values (${name}) returning *`).firstOrThrow("insert returned no row"); sql(`insert into auditLog (event, userId) values ('user_created', ${u.id})`); }); ``` `${value}` interpolations auto-extract as parameterized arguments at build time; SQL injection is impossible by construction. `sql(...)` returns a `SqlResult`: `.all()` for the array, `.first()` for `T | undefined`, `.firstOrThrow(msg)` for a required row. `tx(fn)` commits on return, rolls back on throw. Every `sql()` inside joins the same connection. Full topic: `elements man database`. ### Auth, Errors, Validation ```typescript import { session, AuthError, NotFoundError, ForbiddenError, ValidationError } from "@elements/app"; session.isLoggedInOrThrow(); // throws AuthError if not logged in if (!isAdmin(session.getOrThrow("userId"))) throw new ForbiddenError(); throw new NotFoundError(`room ${id} not found`); // 404 throw new ForbiddenError(); // 403 throw new ValidationError("qty must be positive"); // 422, single rule throw new ValidationError({ email: ["must be a valid email"] }); // 422, per-field ``` `AppError` subclasses are `safe = true`: their messages reach the browser. Internal errors are wrapped in `ServerError` before being sent. - `AuthError` (401): unauthenticated - `ForbiddenError` (403): authenticated but not permitted - `NotFoundError` (404): resource missing - `ValidationError` (422): invalid input (string or `FieldErrors` map) - `NotAcceptableError` (406): request body unparseable - `ScopeMismatchError` (400): LiveTable scope mismatch - `ServerError` (500): safe wrapper for internal errors Errors thrown from an `@rpc` surface in the browser as the same class. Catch with `instanceof` in a `try`/`catch` around the call site. `validate()` does not exist. Throw `ValidationError` directly. Full topic: `elements man rpc`. ### Session The current user's session. Available everywhere on the server and in the browser. Read methods are reactive in the browser; a template that reads `session.isLoggedIn()` updates when the user logs in or out. ```typescript import { session } from "@elements/app"; session.login({ userId, userName }); // server-only session.logout(); // server-only session.isLoggedIn(); // boolean. reactive in browser. session.get("userId"); // SessionData["userId"] | undefined. reactive. session.getOrThrow("userId"); // same, but throws if absent. ``` ```html ``` Session values are typed through the `SessionData` interface. Declare the keys your app stores in `app/types/session.d.ts`: ```typescript declare module "@elements/app" { interface SessionData { userId: string; userName: string; role: "admin" | "user"; } } export {}; ``` `session.get(key)` returns `SessionData[key] | undefined`; `getOrThrow(key)` throws when the key is absent. There is no `getUserId()` / `getUserName()`; read `session.get("userId")`. A logged-out visitor has no session at all (no row, no token, no cookie), so identity is login state, not an anonymous id. Full topic: `elements man session`. ### Sync-style and Async Elements primitives (`sql`, `tx`, `@rpc`, `Channel`, `LiveTable`, `session`) are written sync-style. The compiler rewrites each call to its async variant (`sql` → `await sqlAsync`) and propagates `async`/`await` up the call stack automatically, including across function-expression arguments to schedulers (`setTimeout`, `addEventListener`). For third-party libraries that don't participate (fetch, Stripe SDK, navigator.clipboard), declare your function `async` and `await` those calls directly. The build leaves your manual `async`/`await` alone. For explicit promise control (e.g. `Promise.all`), import the `xAsync` variant (`sqlAsync`, `txAsync`, etc.). ### Channels Typed pub/sub over Postgres `NOTIFY`/`LISTEN`. `LiveTable` uses `Channel` under the hood; use `Channel` directly when you want raw pub/sub without table semantics (push notifications, presence, side-channel events). ```typescript import { Channel, session } from "@elements/app"; interface Alert { userId: string; level: "info" | "warning"; text: string; } const alerts = new Channel("alerts"); export default function route(req: Request, res: Response) { return new html({ alerts: alerts.listen({ filter: (a) => a.userId === session.getOrThrow("userId") }), }); } /** @rpc */ export function postAlert(text: string) { alerts.notify({ userId: session.getOrThrow("userId"), level: "info", text }); } ``` `listen()` returns a subscribed listener. Pass it to a template or return it from an `@rpc`; the browser receives a live, subscribed handle. Full topic: `elements man channel`. ### Routes Url patterns are declared explicitly in `index.ts`, not file-based. Folder names under `app/pages/` are labels. ```typescript import { App } from "@elements/app"; import config from "#config"; import home from "#app/pages/home"; import roomsShow from "#app/pages/rooms-show"; import apiRouter from "#app/api"; const app = new App(); app.route("/", home); app.route("/rooms/:id", roomsShow); app.route("/api", apiRouter); // mount a sub-router app.route("/api/rooms", () => sql(`select id, name from rooms`)); // data route app.start(config); ``` URL patterns use the URLPattern standard. Dynamic segments: `:name`. A page route returns `new html({...})`. A data route returns serializable data (Elements json), or writes to `res` directly with `res.write` / `res.end`. `AppError` subclasses work the same in routes and `@rpc`. A `LiveTable` or listener returned from a route or rpc arrives in the browser as a live, subscribed handle. Fetch initial page data inside the route, not from the browser via `@rpc` after the page loads. Full topic: `elements man router`. ### Jobs and Cron ```typescript // app/jobs/send-welcome.ts import { Job, email } from "@elements/app"; import { WelcomeEmail } from "#app/emails/welcome"; /** @job */ export class SendWelcomeJob extends Job<{ to: string }> { static maxAttempts = 5; run() { email({ to: this.fields.to, subject: "welcome", body: new WelcomeEmail() }); } } ``` ```typescript // index.ts app.cron("every 5m", "rank posts", () => new RankPostsJob().schedule()); app.cron("every day at 2am", "archive", () => new ArchiveOldRowsJob().schedule()); ``` `.schedule()` inside `tx()` joins the surrounding transaction. The job becomes visible only after commit. The project server runs the worker; jobs are stored in Postgres. One machine runs each cron tick (Postgres-arbitrated leader election). Keep cron callbacks small; enqueue and let the worker do the work. Use `/** @job */` and suffix the class name with `Job`. Full topic: `elements man jobs`. ### Tests Tests run as part of every build; failures show up alongside compile errors. Each `test()` body runs inside a Postgres transaction that rolls back at the end, so writes never pollute the test database. ```typescript // app/lib/users.test.ts import { test, assert, equal, sql } from "@elements/app"; test("users", () => { test("create", () => { let user = sql(`insert into users (name) values ('alice') returning *`).firstOrThrow("insert returned no row"); equal(user.name, "alice"); }); test("does not see the previous test's row", () => { let count = sql<{ n: number }>(`select count(*) as n from users`).firstOrThrow(); equal(count.n, 0); }); }); ``` Files: `test.ts` or `.test.ts`, anywhere in the tree. Run `elements test -json` to query state. `test()` calls nest for grouping. Assertions: `assert`, `equal`, `errorf`, `fatalf`. Full topic: `elements man tests`. ### Config `config.jsoc` is evaluated at build time. JSOC is JSON with comments, unquoted keys, trailing commas, and an `env(...)` function. Missing required env vars or wrong types fail the build, not the deploy. ```jsoc { database: { name: "my_app", host: env("DB_HOST", "127.0.0.1"), // default when unset port: env("DB_PORT", 5433), // default when unset }, session: { expires: "30d", // sliding expiry }, } ``` Env vars come from the OS at startup. After startup, edit `config/env/.env` for hot reload. Full topic: `elements man config`. ### Css Style Every page imports `app/shared/styles/page.css`, which imports `@elements/style`, a built-in, CSS-only design system: design tokens for colors, spacing, type, and radii, a reset, finished styles for bare HTML elements, and classes for buttons, forms, cards, callouts, pills, and tabs. Before writing CSS for anything `@elements/style` already covers, read `elements man style` and use what it gives you. Bare elements are already styled. A `