Best practice for AI agents creating or maintaining sites on the lazysite engine - the separation-of-concerns model, the failure modes to avoid, and the sanctioned pattern for live data.
A guide for AI agents (and the people directing them) creating or maintaining sites on the lazysite engine. It captures the separation-of-concerns model the engine is built around, and the failure modes that show up when an agent treats site-building like generating a single web page.
This is the "how to think about a lazysite site" briefing. For the mechanics see its siblings: content authoring, layouts and themes, publishing over WebDAV / the control API, configuration, and - for importing an external design - integrations / Figma.
lazysite is built on a strict separation. Keep it.
| Layer | What it is | Where it lives | Who owns it |
|---|---|---|---|
| Content | The words and structure of a page | Markdown .md (or .url) files in the docroot |
Authors (human or AI), non-technical |
| Layout | The HTML chrome: header, nav, footer, page scaffolding, reusable features | layout.tt (+ components/*.tt) |
Site builder |
| Theme | Colours, fonts, spacing, assets - expressed as design tokens | theme.json -> CSS custom properties, main.css |
Designer |
The engine renders content through a layout, styled by a theme, and caches the result. Three sanity checks tell you whether you have kept them separate:
If any answer is "no", you have fused layers that should be apart.
The most common mistake an AI makes is to generate one big HTML file with the markup, all the CSS, and the content fused together - a "web page", not a site.
united.explore.lazysite.io/lazysite-assets/summit-of-summits/ - a single page
built by an assistant:
<style>.<div>s, 10 <section>s of bespoke structure.var(--theme-*) refs - so it
half-knew about tokens, then hardcoded anyway)./lazysite-assets/… - the static-assets path. It bypasses
the rendering engine entirely: no layout.tt, no site nav, no theme system,
no cache pipeline, not registered in sitemap/llms/feeds.main.css.llms.txt entry - it is a dead end in the site.That page is a look (a distinctive editorial theme) plus content. So:
summit): its palette, fonts and
spacing as tokens in theme.json; shared CSS in main.css.Result: the same design, but reusable across a whole site, themeable, authorable, cached once, and on the rails.
The monolith almost always traces back to one front-matter switch: api: true
(equivalently raw: true). That is raw mode - the engine serves the file's
body verbatim and skips the layout and the theme. A page built that way has to
carry its own header, nav, footer and every line of CSS, because the shared
chrome and the design tokens are never applied to it. Put ordinary pages in raw
mode and you are hand-manufacturing monoliths, one per page.
Raw mode exists for a genuinely self-contained artifact - a single bespoke interactive widget or an embed fragment with its own content type. It is not for content. For a normal page, write plain Markdown (title/subtitle front matter, a prose body) and let the engine wrap it in the shared layout + theme.
Most of the "mechanical" busywork that raw-mode publishing seems to demand is a symptom that disappears the moment a page is Markdown:
img/ paths - unnecessary. In Markdown you
write  and upload the image once; images are only ever
"embedded" because something authored a self-contained HTML blob.preview_page (server-side
render) or a plain GET of the page URL. Chunked source re-reads are a
workaround for a problem the approach created..md - front matter, words, and . Upload the
page (and any new image), clear the cache, verify with a rendered GET. Seconds,
not surgery.The scaling maths is the punchline. Monolith pattern: every page re-ships its own copy of the design (tens of KB), every edit is big-file surgery, and a restyle means touching every page - cost grows with the page count. Theme + layout pattern: the design is defined once (O(1)), each page is a few KB of content, and restyling the whole site is a single theme swap. As a site - or an estate of sites - scales, those two curves diverge hard: one gets slower and heavier per page, the other stays flat.
.md, wrapped by the engine in the shared layout + theme. Do not set
api: true / raw: true on a content page: that is raw mode, which serves the
body verbatim with no layout and no theme, and is the usual source of monolith
pages. Reserve raw mode for a genuinely self-contained artifact. If you are
writing paragraphs of HTML in a page, stop - it belongs in the layout.
This is now enforced (SM189): the write path - manager save, MCP write,
and WebDAV PUT - refuses a page that declares api:/raw: with a
script-capable content_type (text/html, XHTML, SVG). You get a clear error,
not a silently-broken page (raw mode is served as plain text, ADR 0006). That
also closes the route by which such a page smuggled in external CSS/font links;
fonts and CSS must be bundled (OFL/Apache), never loaded from a CDN such as
fonts.googleapis.com. A genuine artifact (a non-script content_type like
application/json) is still allowed.layout.tt. Build them once; every page inherits them.var(--theme-*); never hardcode a
colour or font in a page. A theme swap must be able to restyle everything.FOREACH loop (or a components/*.tt component), so reordering can't break
the markup and non-technical people can edit the data. Hand-written repeated
<div>s are a smell.tt_page_var), so any page can opt in - don't bolt bespoke markup into a
single page./lazysite-assets/ (or any static path) as a "page". Assets are for images,
video, fonts, main.css - not documents.<style>
is growing past a few lines, it wants to be in the theme.sitemap.xml / llms.txt, emit
feeds where relevant. Server-rendered semantic HTML is what makes lazysite
sites findable by search and AI.lazysite-agent/<your-partner-id> (e.g.
--user-agent "lazysite-agent/claude-dhcf"). The visitor-stats classifier
treats that token - and the legacy claude-code-agent - as tooling, not a
human visitor, so your QA hits stay out of the audience numbers.<head> (e.g.
<link rel="icon" href="/favicon.svg">, served from the site root or an
asset path) - never a hotlinked / CDN icon (same rule as fonts). Put the
<link> in the layout so every page inherits it; one favicon per site.api: true / raw: true, with its own content_type) -
never an ordinary content page.When you meet a page like summit-of-summits:
theme.json tokens;
move shared rules into the theme's main.css. Replace every hardcoded hex
with a var(--theme-*)..md that selects the
layout+theme and carries the words; register it for sitemap/llms/feeds.Done once, the design is now reusable, themeable and authorable - and the next page in that style costs almost nothing.
Fonts, photographs, icons and JavaScript libraries belong on the site's own
origin, never a CDN and never a hotlink to someone else's server. That has always
been the rule; the tool to follow it is upload_file.
write_file is text only and will corrupt binary content. Use upload_file,
which takes path and content_base64 - the bytes as standard base64, newlines
in the payload are fine:
{ "path": "assets/fonts/inter.woff2", "content_base64": "d09GMgABAAAAA…" }
Same permissions and refusals as write_file: engine-owned paths, executable
extensions and your scope confinement all apply unchanged, and the size cap is
named in the refusal if you exceed it.
woff2 under the theme's assets/, then reference it
from the theme CSS with a relative path. Do not write @import
url('https://fonts.googleapis.com/…') - that sends every visitor to a third
party and breaks the site's privacy position.favicon.ico and apple-touch-icon.png are binary, so upload them.
A site with no favicon is a site that looks unfinished.Permissions are the same as write_file - manage_content, or
manage_themes / manage_layouts for a path under a layout or theme - and the
same refusals apply: engine-owned paths, executable extensions and your scope
confinement are unchanged. The size cap is the site's upload limit and is named
in the refusal if you exceed it.
If an asset is genuinely too large for the cap, say so and ask the operator rather than reaching for a remote URL.
A page that shows what another system knows - a price, a queue length, a status - is built from parts the engine already has, and this chain is the sanctioned way to do it:
schedule -> connector handler -> remote source -> a data-table row -> page
An outside process that produces live data is not ported into lazysite: it stays its own engine, and the site reads what it publishes through this chain.
A data table for the answers, with exactly the columns a connector
writes. Make it public: true if visitors read it:
yaml
public: true
key: id
auto_key: true
fields:
connector: { type: text }
call_id: { type: text }
mode: { type: text }
actor: { type: text }
at: { type: integer }
state: { type: text }
http: { type: integer }
answer: { type: text }
A connector with modes.scheduled on and answer_table naming that
table. The operator saves it on the Connectors page; an agent cannot (see
connectors). With method: GET the fixed fields travel
as the query string.
A connector handler naming the connector, and a schedule entry
naming the handler, with an interval and the fixed fields to send:
```yaml schedule:
The daemon's job account (daemon_job_user) holds run_jobs and
manage_connectors.
A page that binds the newest row:
yaml
tt_page_var:
latest: db:prices(order=-id,limit=1)
or a data-ls-db region that the shipped helper fills from
/cgi-bin/lazysite-data.pl (see data tables).
Every poll appends a row; nothing replaces the last one. The row is the call
record - connector, call_id, mode (scheduled), actor (the job
account), at, state, http - plus answer: the remote's whole JSON reply,
encoded as one text value (at most 64 KB). A template renders it as that text,
escaped, and cannot reach inside it; a page script reading the endpoint gets the
same text and parses it (JSON.parse(row.answer)). The engine never removes
these rows: at the floor one entry adds 288 a day, so delete old ones yourself
(delete_data_row) or size the table for it.
The cadence floor is 300 seconds. An entry below it is refused when saved;
one written by hand into schedule.conf never runs, is recorded as refused in
the daemon's run record the first time it is met, and lazysite check names it.
The schedule is run by an engine job that itself runs every 300 seconds, so an
interval rounds up to a multiple of 300: every: 450 polls every 600
seconds. On the wall clock add up to one daemon tick (daemon_tick_seconds,
default 60) of lateness. There is no faster path.
Single-call sources only. Each poll sends the same fixed fields, and nothing from one answer reaches the next request. A source that wants submit, then poll until done, then read the result cannot be expressed: every poll would submit a new job. A sweep over several items is one schedule entry per item - a payload cannot carry a list - and every entry through one connector counts against its rate cap (default 60 an hour, so five entries at the floor fill it; the sixth is refused and recorded as failed). A single static request is what this does well.
The cost of a poll is small. One HTTP call, one row, one call-record line and
one audit line, and it does not grow with visitors. The scheduler runs its jobs
one after another, so a slow source holds the others for up to the connector's
timeout (at most 60 seconds); keep it short. A browser reading the endpoint
costs one request per read, which is small at low visitor counts. How live the
data can be is decided by where the sweep runs - the 300-second schedule - not
by what a poll costs.
A table write refreshes the pages that read the table. No cache clear is
needed. Every poll's row touches the table's change stamp, so a page that reads
the table shows the newest row on its next request, a page with ttl: included
(from 0.16.8). A ttl: N still bounds a page whose table has not changed: it is
served from its cached render for N seconds. mode=live does nothing today: it
behaves exactly as snapshot does.
mode=client still reads the rows at render too; what makes a page client-side
is a data-ls-db region. The endpoint answers Cache-Control: no-store, so a
region with data-ls-db-every re-reads it each time - but reading more often
than the schedule polls shows nothing newer.
The endpoint refuses what it does not read. It reads table, order_by,
order, limit and offset, and ignores _ (a cache-buster). Any other
parameter is a 400 with kind: unknown-parameter and no rows - filter on the
page binding, not in the query string.
json: data ASCII - the data loader drops non-ASCII (use - not em
dashes, straight quotes, -> not arrows).<style> must be a single line - a leading # on its own line is
parsed as a Markdown heading and mangles the CSS.<strong> inside a
<div>, not **bold**.read_file answers the file's
mtime (read_page calls it modified); pass it to write_file as
base_mtime. If anyone saved the file after your read, nothing is written and
the answer is a conflict naming the current mtime: read it again and make the
change on what it holds now. Without base_mtime the write replaces whatever
the file holds. replace_text does this for you.lazysite-agent/<partner-id> UA (see rule 10) so QA
traffic stays out of the human analytics./lazysite-assets/ or other static paths.api: true / raw: true) - only true self-contained artifacts do.var(--theme-*).upload_file.