Documentation · v0

The whole language
on one page.

Lith's docs are its language card: one page a model reads once before writing working Lith. In blind tests, a model that had read only this page built 8 of 8 unseen apps.

2,349tokens, all of it
9sections
8 / 8blind apps passed

Give it to your agent

Paste the card into the context, or point the agent at /llms.txt. The full specification with every diagnostic code is in /llms-full.txt.

llms.txtllms-full.txt
// example prompt Read https://lith.codes/llms.txt, then build the app in Lith.

Lith = TypeScript + JSX + Prisma-style queries + the declarations table page api crud layout (at line start) and state/effect/auth. All .lith files share one global namespace: never import your own files or Lith names; import npm packages normally. JS built-ins (Date, Set, fetch, setTimeout, regex) work as in TS. Server code needs no await (harmless).

Tables

table Post {
  title: string(1..200)            // refinement: length 1..200, checked on every write
  body: string                     // any text, "" allowed
  slug: string(1..80) unique       // modifiers (unique, index, renamedFrom("old")) go anywhere after the base type
  views: int(0..) = 0              // int(a..b) / number(a..b): value range
  done: boolean = false
  status: "Draft" | "Live" = "Draft"
  due: day | null                  // day = "YYYY-MM-DD" (from <input type="date">); compare with today()
  author: User | null              // reference: field authorId; include: { author: true } loads it
  tags: Tag[]                      // many-to-many (join table is automatic)
  created: Date = now()
}
  • id: int is automatic; never declare it. T | null = optional. A new column with a default or | null needs no migration. To rename a column and keep its data: unitPriceCents: int(0..) renamedFrom("priceCents").
  • table Note own { ... } adds owner: User, fills it on create and limits every query, update and delete to the logged-in user's rows (others act as missing; login required). Use it only for private data. Shared rows with an owner get owner: User plus checks in code. system(() => ...) runs code without own scoping; it is allowed only in auth(...) pages/functions, test and seed { } blocks.
  • User exists (email: email unique, name: string, passwordHash; email = text "@" text "." text). Add columns with table User { role: "Admin" | "Member" = "Member" }. passwordHash never reaches the browser or JSON. Table names may be any word (Image, Event).

Queries (Prisma Client)

db.post.findMany findFirst findUnique findUniqueOrThrow count create update delete updateMany deleteMany with where data orderBy skip take include; operators equals not in contains startsWith endsWith lt lte gt gte AND OR NOT (contains and string sorting ignore case). findUniqueOrThrow → the row or a 404; update/delete of a missing row → 404. Nulls: orderBy: [{ done: "asc" }, { due: { sort: "asc", nulls: "last" } }]. Many-to-many: data: { tags: [tagA, tagB] } sets the list, where: { tags: { some: { name } } } filters, include: { tags: true }. A row loaded with include has those fields in its type: a posts: Post[] prop may read p.tags if the page loaded them.

Pages and layout

auth page "/posts/:id/:slug" (id: int, slug: string, q: string = "") {   // path params typed and URL-decoded;
  const post = db.post.findUniqueOrThrow({ where: { id } })            // other params come from the query string
  if (slug !== post.slug) redirect(`/posts/${id}/${post.slug}`, 301)   // default status 303
  return <main><title>{post.title}</title><h1>{post.title}</h1><p>{post.created}</p></main>
}
layout {                                // optional; wraps every page; `children`, `me` and `path` are in scope
  return <html data-theme="system">
    <nav><a href="/" class={path === "/" ? "font-bold" : ""}>Home</a></nav>
    {children}
  </html>
}

auth page requires login (else redirect to /login); auth(me.role === "Admin") page also answers 403. me is the logged-in User or null (User under auth); path is the current URL path. A bad typed path param gives 404. Links are plain <a href>; a search form is <form method="get">. A Date renders as 2026-10-16 14:05. <html data-theme="light" | "dark" | "system"> in layout forces or frees light/dark (built-in styles and Tailwind dark:). Stop anywhere with redirect(path, status?), notFound(), forbidden(), unauthorized(), fail(...).

Server functions and forms

A top-level function that uses db, me, login/logout/loginWithPassword/hashPassword or process.env runs on the server; the browser calls it automatically. auth function requires login (401). Parameters are typed and validated.

<form action={addComment}><input type="hidden" name="postId" value={post.id} /><textarea name="body" /><button>Send</button></form>
auth function addComment(postId: int, body: string(1..1000)) {
  if (body.includes("http")) fail({ body: "No links" })      // field message; fail("msg") = form message
  db.comment.create({ data: { postId, body, authorId: me.id } })
}
<button onClick={() => deleteComment(c.id)}>Delete</button>    // in a page a handler is one server-function call;
                                                               // anything else needs a component with `state`
  • Inputs, textareas, selects and hidden inputs fill parameters by name, converted to the parameter type (int, number, checkbox → boolean, type="date" → day, repeated input names → T[]). An empty field is "" for string and null for T | null.
  • All parameter checks run first, all at once. email: User.email reuses a column's type; email: User.email unique also checks it is not taken. For your own message, type the param loosely (url: string) and fail in the body.
  • On any failure the form re-shows by itself: each field message after its input, other messages at the end, typed values kept (not passwords). Nothing to write. Without JS it is a native POST. fail("Already voted", 409) sets the status (default 400).
  • After a server function succeeds, the page re-renders from the server: island props update, state is kept and the form resets. redirect navigates.

Components and client state

function Search(placeholder: string, onPick: (id: int) => void) {   // props are parameters: <Search placeholder="Find" onPick={f} />
  state q = ""
  state hits: Person[] | null = null                // typed state
  const empty = hits !== null && hits.length === 0  // recomputes
  effect(() => {                                    // runs after render and whenever q changes
    if (q === "") { hits = null; return }
    let stale = false
    const timer = setTimeout(async () => {
      const res = await fetch(`/api/people?q=${encodeURIComponent(q)}`)
      if (!stale) hits = await res.json()
    }, 300)
    return () => { stale = true; clearTimeout(timer) }   // cleanup before the next run
  })
  return <div><input value={q} onInput={e => q = e.target.value} placeholder={placeholder} />{empty && <p>No matches</p>}</div>
}

No React hooks. A component with state runs in the browser too. It cannot read db or me, so pass rows as props. It may fetch the app's own routes. Server functions called from it are async: await them in try/catch (e.status, e.message). A component without state renders on the server and may read db and me. children: Html receives nested JSX. In handlers, e.target is the element (e.target.value, e.target.checked).

APIs

crud "/api/products" Product         // GET list, GET /:id (404 {"error":"not found"}), POST 201, PATCH /:id, DELETE /:id 204
crud "/api/notes" Note: list, get    // only some operations
auth api GET "/api/orders.csv" (from: day | null) {   // params from path, query string or JSON body
  return new Response(csv, { headers: { "Content-Type": "text/csv" } })   // or return a value → JSON 200
}

Errors are JSON: 400 {"error":msg,"field":f}, 401, 403, 404. Response.json(v, { status: 201 }) sets a status.

Auth

loginWithPassword(email, pw) → User | null, and it starts the session on success. login(user), logout(), hashPassword(pw) (scrypt).

Styling

Plain HTML already looks good (centered main, form as a column, li as a row, no ul bullets, styled controls, tables and links). Add Tailwind via class (not className, no style) only for extras: flex-row font-bold text-red-600 text-gray-500 whitespace-pre-wrap grid grid-cols-3 gap-4. Class strings may be combined (ok ? "a" : "b", p-2 ${x} ), but each class name must appear whole somewhere, never "bg-" + c.

Check and test

lith check reports the first error as JSON with a patch. test("name", () => { ... }) may sit in any file: fresh database per test, vitest expect, asUser(user, () => ...).