Start · docs/agent-guide.md
dhtml: guide for AI agents and for people in a hurry #
Read this first. It is short on purpose and covers what most apps need. The full references are syntax.md, templates.md and config.md. Never invent a tag, attribute or filter that is not listed there: the check that runs on every upload will refuse it, and the error names the file and line.
The loop #
dhtml sites run on dhtml.net. There is nothing to install and no local server: you write the files of the project, zip them and upload them to your site.
# write dhtml.config, server/migrations/001_*.sql, pages, actions, layouts, public/
# zip the CONTENTS of the project folder (dhtml.config at the top of the zip; no .env, no server/data)
# upload the zip: the Deploy page of your site on dhtml.net, or the deploy API
# read the result; fix every error, read every hint, upload againEvery upload is checked before it goes live: templates, routes, config and migrations are compiled, the database is backed up, and migrations are applied. A check that fails says the file, the line and a hint, and the live version of the site is left as it was. Upload after every few files, not at the end. Secrets (API keys, passwords) are not in the zip: they are set in the Secrets section of the site's page on dhtml.net and are named env:NAME in dhtml.config. To deploy from a script or an AI assistant, use a deploy key: see dhtml.net/ai/deploy-api.txt.
Files #
| Where | What |
|---|---|
server/pages/**/*.dhtml | GET pages. The path is the URL: blog/[slug].dhtml is /blog/:slug, read as {{ route.slug }} |
server/api/*.dhtml | Actions (forms, JSON). server/api/save.dhtml is POST /api/save |
server/layouts/ partials/ components/ steps/ | Shell with <d:slot />; fragments (<d:include src="x" />); pieces with inputs (<d:props a b="default" />, <d:component src="x" a="..">...</d:component>, read as props.a); steps (<d:use src="x" />: queries, checks and guards written once, used in pages, actions and jobs) |
server/jobs/*.dhtml | Work later or by the clock |
server/migrations/NNN_name.sql | Plain SQL, applied in order. Put sample/seed rows here |
public/ | Static files (CSS, images, JS). Served as they are |
dhtml.config | Settings (INI). Secrets are set in the Secrets section of the site on dhtml.net and are named env:NAME |
Values and logic #
{{ post.title }} {{ post.created | date }} {{ body | truncate 80 }} {{ user.name | default "Guest" }}
<d:if test="post.likes > 10 and not post.hidden"> ... <d:else> ... </d:if>
<d:for each="p in posts"> {{ p.title }} <d:empty> Nothing yet. </d:for>
<d:auth> signed in <d:else> not </d:auth> <d:auth admin> admins </d:auth> <d:guest> visitors </d:guest>
<d:status code="404" />- Every
{{ }}is escaped for where it lands. There is no raw output.markdownandjsonare the only filters that make HTML or script, and they must be last. - Filters:
date upper lower default truncate slug markdown json first count add sub mul div round fixed thousands sha256 base64 urlencode. Prices:{{ cents | div 100 | fixed 2 }}shows12.50;{{ total | fixed 2 | thousands }}shows1,234,567.50;{{ d | date "02 Jan 2006" }}takes a layout in quotes. Atestcannot use a filter (test="rows | count"is an error): test the value itself, because an empty list, empty text,0and nothing are all false (test="rows"), or compare with== != < > <= >=. Alsonot,and,or; no parentheses. Strings in a test use double quotes, so write the attribute with single quotes:test='b.status == "booked"'. - There is no number or date formatting filter. Format in SQL:
printf('%.2f', price_cents / 100.0),strftime('%d.%m.%Y', date). - Built-in names:
app route query form errors user page config csrf flash. <d:...>tags can never sit inside an HTML tag or attribute. So there is no "selected" helper: write two whole elements, one in each branch:
<d:if test="s.id == form.service"><option value="{{ s.id }}" selected>{{ s.name }}</option><d:else><option value="{{ s.id }}">{{ s.name }}</option></d:if>Pages read data #
<d:query as="posts"> SELECT id, title, slug FROM posts WHERE published = 1 ORDER BY id DESC LIMIT 20 </d:query>
<d:query as="post" one> SELECT * FROM posts WHERE slug = {{ route.slug }} </d:query>
<d:if test="not post"><d:status code="404" /></d:if>
<d:layout src="main" title="{{ post.title }}"> <h1>{{ post.title }}</h1> </d:layout>- Do not copy a rule between files. Put the query that finds the shop, the settings every page shows, or a check like "this person belongs to this shop" in
server/steps/name.dhtmland write<d:use src="name" />where it applies (page top level, or among the steps of an action after<d:validate>). A steps file holds only steps, reads the same variables as its user, and can end the request with<d:status code="403" />or<d:redirect>. - Queries go at the top level of a page (not inside
<d:if>,<d:for>, a layout or a partial). In an action, queries, writes and mails may sit inside<d:if>. Pass results to a layout as attributes (page.title). - Each
{{ }}in SQL is a bound value: one plain value, no filters, never a table or column name, never afterFROM/JOIN/ORDER BY. An empty value is NULL in SQL, not'': a missing?date=makes{{ query.date }} = ''match nothing. WriteCOALESCE({{ query.date }}, '') = ''or{{ query.date }} IS NULL; The check warns about the comparison with''. Build patterns in SQL:LIKE '%' || {{ query.q }} || '%'. UseASwhen two columns share a name. Max 10000 rows. - A layout sees only built-ins and
page.*, not the page's variables, and cannot run a query: something every page shows (the site name) is queried at the top of each page and passed with<d:layout src="main" space="{{ settings.name }}">.
Actions write data #
<!-- server/api/post-save.dhtml -->
<d:action method="POST" auth ratelimit="20/minute">
<d:validate field="title" required max="120" /> <!-- ALL validates first; only declared fields exist -->
<d:validate field="body" max="20000" />
<d:exec as="saved"> INSERT INTO posts (title, body, user_id) VALUES ({{ form.title }}, {{ form.body }}, {{ user.id }}) </d:exec>
<d:redirect to="/post/{{ saved.id }}" flash="Saved" />
<d:invalid> <p>{{ errors.title }}</p> </d:invalid> <!-- status 422, shown when validation fails; HTML only, no <d:redirect> -->
</d:action>- Forms:
<form method="post" action="/api/post-save">. dhtml adds the CSRF token by itself. Never write one. ratelimitis required for anything that logs in, mails, calls a model or another service, and enqueues.auth= signed in,admin= admin. AGETaction cannot write.ratelimitcounts tries per visitor address, so a shared office or school share one allowance:30/minuteis plenty for a booking form.- Validation rules:
required email number date time min max file types message.dateisYYYY-MM-DDand a real day;timeisHH:MM. There is no pattern rule: check other shapes in SQL (see the booking pattern). - Every path ends in exactly one response:
<d:respond>(HTML),<d:json>,<d:redirect to="/path" flash=".." />,<d:status code="403" />. A redirecttostarts with/. There is no custom status for a redirect, so "slot taken" is a redirect with a flash. <d:exec>is oneINSERT/UPDATE/DELETE/REPLACE(no schema changes, noRETURNING, no names startingsqlite_or_dhtml_).saved.idandsaved.changesare available after it. The whole action is one transaction: it rolls back on any error,<d:status>400+, or invalid input.- Filters do not work in SQL. Compute first:
<d:set as="cents" value="{{ form.price | mul 100 | round }}" />, then{{ cents }}. - Uploads:
<d:validate field="photo" file types="png,jpg" max="2MB" />withenctype="multipart/form-data";form.photois a stored name, shown at/uploads/{{ form.photo }}. - Do not call a model (
<d:ai>) or<d:http>after a database step in an action that writes; do the call first, the writes after.
Patterns that cover most apps #
Check and insert in one statement (no double booking, no oversell). Do every check inside the INSERT ... SELECT ... WHERE, then read changes. SQL in <d:exec> and <d:query> may not contain comments; explain it outside:
<d:set as="slot" value="{{ form.slot | add 0 }}" /> <!-- a number, from the text a form sends -->
<d:exec as="saved">
INSERT INTO bookings (service_id, date, start_min, name)
SELECT s.id, {{ form.date }}, {{ slot }}, {{ form.name }} FROM services s
WHERE s.id = {{ form.service }} AND s.active = 1
AND date({{ form.date }}) = {{ form.date }}
AND {{ form.date }} >= date('now')
AND NOT EXISTS (SELECT 1 FROM bookings b WHERE b.date = {{ form.date }} AND b.start_min = {{ slot }})
</d:exec>
<d:if test="saved.changes == 1"> <d:redirect to="/thanks" /> <d:else> <d:redirect to="/book" flash="That time is taken." /> </d:if>date(x) = x makes sure the text is a real YYYY-MM-DD date. For a service that lasts longer than one slot, test overlap instead of equal start times: b.start_min < {{ slot }} + s.duration_min AND {{ slot }} < b.start_min + b.duration_min. Add a UNIQUE index in the migration as a second wall. A time kept as text can be checked with GLOB '[0-2][0-9]:[0-5][0-9]'. Languages: [lang] in dhtml.config, one file server/lang/<code>.lang per language (key = text, long text as an indented block, {1} places), {{ "nav.home" | t }} in pages, {{ lang.prefix }} in links, the check on every upload lists missing keys; a mail uses lang="b.lang" and {{ "key" | t }}. Never write the words of a second language into the pages. Signing: {{ value | hmac "env:NAME" }} (also hmac_base64, hmac_base64url) in the steps of an action or a job only, with the key in .env; check a signature by signing again and comparing with ==. Never sign what a visitor sent and hand it back. An unguessable code (a personal link): code TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))) in the migration, then read it back with a one query after the insert and put it in a link or a mail: https://site/b/{{ b.code }}. SQLite functions work in SQL. Foreign keys are on, so ON DELETE CASCADE works.
Settings in one row. CREATE TABLE settings (id INTEGER PRIMARY KEY CHECK (id = 1), shop_name TEXT NOT NULL, ...) with INSERT INTO settings (id, shop_name) VALUES (1, 'My shop'), one query ... one, and an admin action that UPDATEs it.
Login, logout, protected pages.
<d:action ratelimit="10/minute"> <!-- server/api/login.dhtml -->
<d:validate field="email" required email /> <d:validate field="password" required /> <d:validate field="next" />
<d:login /> <d:redirect next="/admin" flash="Welcome back" />
<d:invalid> <p class="error">{{ errors.form }}</p> </d:invalid>
</d:action><d:logout /> is the same shape. Protect whole areas in dhtml.config, not page by page: [protect] then /admin = admin and /admin/* = admin (= auth for signed-in). A login page must exist at /login. The first admin of an app without sign-up claims the account: set ADMIN_EMAIL in the site's Secrets, write admin_email = env:ADMIN_EMAIL under [auth], open /_dhtml/claim (a one-time link comes by mail). Accounts are made by <d:register /> (needs [auth] registration = on, [mail], url), or by magic links (magic_login = on). Sign-in with Google is [oauth] (see config.md).
Admin list with per-row buttons. One small <form method="post" action="/api/item-toggle"><input type="hidden" name="id" value="{{ i.id }}"><button>Hide</button></form> per row, each with its own action; declare id with <d:validate field="id" required number />.
Tell the owner. <d:notify subject="New order from {{ form.name }}" fields="name, email, message" reply="email" /> mails the one address in [mail] notify. Every field must be declared; values print as raw field: value lines. To confirm to the customer, use <d:mail to="email" subject="Your booking on {{ form.date }}" fields="date">Thank you, {{ form.name }}.</d:mail>: to is a declared email form field (or user.email), text and {{ form.x }} values only, plain text, and the action needs a ratelimit. There is no way to mail any other address; links typed by a visitor are removed. At most 3 mails to one mailbox a day (a +tag and Gmail dots count as the same) and [mail] limit_per_hour (60) for the site: a mail over a limit is dropped and the visitor is told nothing. The text can use declared form fields and plain results of earlier steps ({{ saved.id }}, {{ w.title }} of a one query, a <d:set> code), so {{ config.app.url }}/b/{{ code }} works (url is set in [app]; never hard-code the address). to is an email field, user.email, or a column of an earlier <d:query as="next" one> (to="next.email", e.g. the next person on a waitlist).
Mail to many rows and HTML mail. A job or an admin action can mail every row of an earlier list: <d:mail for="b in due" max="50" to="b.email" subject="...">text with {{ b.title }}</d:mail> (max 1 to 100 is required; mark the rows done in a <d:exec> of the same job so a retry mails nobody twice; the mails go out after the commit). For a mail that must look like something, write HTML in server/mails/name.dhtml and use <d:mail to="email" subject="..." template="name" />: a short list of tags, values only in the text (and in a link path after {{ config.app.url }}/), no script, form or remote image, and a text part made for you. Details: templates.md.
Many shops in one app, by name. request.host is the name the visitor used. At the top of each page: <d:query as="shop" one>SELECT * FROM shops WHERE host = {{ request.host }}</d:query> and <d:if test="not shop"><d:status code="404" /></d:if>; then put shop.id in the WHERE of every other query and write. A name is not a permission: still use auth and check that the person belongs to that shop. Cookies belong to one name, so a login on one shop is not a login on another.
Work later. server/jobs/x.dhtml starts with <d:job retries="3" timeout="30">; steps are the same as an action's. An action queues it with <d:enqueue job="x" arg="{{ id }}" />; a job with every="1d" at="03:30" runs by the clock.
Another service. <d:http as="r" method="POST" url="https://api.example.com/v1/x" bearer="env:KEY"><d:field name="a" value="{{ v }}" /></d:http>; the host must be in [http] allow, the key in .env. Read r.ok, r.status, r.json.x. Add soft to get r.ok = false instead of an error.
A model. <d:ai as="answer"><d:system>..</d:system><d:user>{{ form.q }}</d:user></d:ai> in a non-GET action with ratelimit. [ai] provider = fake needs no key (fixed answer) for development; anthropic or openai need key = env:NAME. Model output is untrusted and escaped.
Config you will write #
[app]
name = myapp
version = 0.1.0
url = https://myapp.dhtml.net # the address of the site; links in mail are built from it
[auth]
registration = off # on: sign-up by email link (needs [mail])
[mail]
transport = log # mail is written to the log; use smtp for real mail (host, port, username, password = env:X, from)
notify = owner@example.com # where <d:notify /> goes
[protect]
/admin = admin
/admin/* = admin
[http]
allow = api.stripe.com # the only hosts <d:http> may call
[routes]
/sitemap.xml = api/sitemap.dhtml # map a path to a file; also: redirect /x, text ok, json {"a":1}Errors you will meet #
| Message (shortened) | Fix |
|---|---|
unknown variable "pots". Did you mean "post"? | Spelling, or the name is not in scope (layouts and components see only built-ins and their inputs) |
<d:validate> must come before the steps | Move all <d:validate> to the top of the action |
the form field "x" is not declared | Add <d:validate field="x" /> before using form.x |
a GET action cannot write | Use method="POST" for anything with <d:exec> |
{{ }} after FROM/ORDER BY/... is a compile error | A value cannot be a name. Write the name in SQL; choose between a few with separate queries in <d:if> |
tag inside an HTML tag or attribute | Put the <d:if> around the whole element |
a model call / http call after a database step | Make the call first, then the writes |
needs a ratelimit | Add ratelimit="10/minute" |
[protect] sends visitors to /login, but no page answers there | Create server/pages/login.dhtml |
step name used twice / two columns with one name | Names (as=) must be unique in a file; use AS in SQL |
account tables do not exist | Upload again: migrations are applied on every upload, and the account tables are made by them |
Rules of the house #
- Link the stylesheet as
/style.css?v={{ app.version }}: the browser then keeps it for a year, and a new build is a new address. - Plain HTML and CSS in
public/style.cssare enough; the page policy forbids inline<script>andonclick. Put JavaScript inpublic/and pass data withdata-*attributes. - Show every value through
{{ }}; to show user text with paragraphs use{{ text | markdown }}. - Keep secrets in the Secrets section of your site on dhtml.net, never in a page or the config. Keep names (tables, columns, routes) fixed text.
- Prefer a starter over starting from nothing when the app is close to a blog (
launchblog) or an AI product with accounts, credits and Stripe (launchsaas): download its zip from the examples page on dhtml.org, change the files and upload it to your site. - When something is not in these docs, it does not exist. Say what you could not build instead of working around the language.
