This briefs an automated publishing partner - an AI agent that holds write access to the docroot over WebDAV. It covers connecting, authenticating, the path mapping, scope, the WebDAV operations, the control API, and cache behaviour.
For content rules (front matter, Markdown, URLs) see
AI briefing - content authoring. For layouts and
themes see AI briefing - layouts. For configuration
see AI briefing - configuration. For analysing
visitor traffic (with the analytics permission) see
AI briefing - visitor analytics. For keys,
variables, and file locations see Reference.
You are given one document out of band: an onboarding brief. Everything
else is discoverable from it. The brief carries a machine-readable block
(under a ## Machine-readable heading) - parse your identity, scope, and
endpoints from that block, not from prose. A partner-agnostic copy is published
at /.well-known/ai-partner.
The brief describes the grant; the token is the grant; the server
enforces it. Treat the scope in the brief as advisory about what to attempt;
the server is authoritative and rejects anything outside it. If an in-scope
write returns 403, the grant - not the documentation - is right.
Before authenticating, do one cheap unauthenticated request - GET
https://SITE/.well-known/ai-partner (it also returns your machine-readable
bootstrap). This proves your environment can egress to the host.
403 whose body names an allowlist (host_not_allowed, "add this host to your
egress settings"): stop and report that immediately. The block is on your
side; the host must be added to your network egress allowlist. Do not retry
blindly, and do not conclude the site is down - you have not reached it yet.
Note that a pairing key denied at your proxy is not consumed (it never
reached the origin), so it is still live - say so in your report.Report which of these you got before going further, so a human can act on the right thing.
You hold a single-use, short-lived pairing key (prefix lzp_). You exchange it
once for a working access token (prefix lzs_).
POST the pairing key to the exchange endpoint. The JSON response carries
the token and its expiry as an epoch timestamp.
claude-dhcf), and the wrong username returns 401.
401, rotate if you still hold a recently valid token;
otherwise the operator must re-issue the pairing.
# Exchange the pairing key for an access token
curl -s -X POST "https://SITE/cgi-bin/lazysite-auth.pl?action=exchange" \
--data "username=PARTNER&pairing_key=lzp_..."
# -> { "ok": true, "token": "lzs_...", "expires_at": 1750000000 }
# Rotate before expiry (current token as Basic auth, no body)
curl -s -X POST -u "PARTNER:lzs_..." \
"https://SITE/cgi-bin/lazysite-auth.pl?action=rotate"
Read expires_at from the exchange and rotate responses so you rotate on
schedule rather than waiting for a 401. Both endpoints are HTTPS-only and
rate-limited.
A cheap, side-effect-free probe that the token is live:
PROPFIND /dav/ Depth: 0 Authorization: Basic base64(PARTNER:lzs_...)
-> 207 = authenticated; 401 = wrong username or token.
For your full grant - capabilities, groups, scope, and the plugins, layouts, and themes the site offers (with active flags) - introspect over the control API rather than assuming from the bootstrap:
GET /cgi-bin/lazysite-manager-api.pl?action=whoami
Authorization: Basic base64(PARTNER:lzs_...)
-> { partner, capabilities, groups, scope, layouts, themes, plugins, site_capabilities }
https://SITE/dav/ - content, assets, layout/theme files, and nav.conf.
https://SITE/cgi-bin/lazysite-auth.pl?action=exchange and ?action=rotate.
https://SITE/cgi-bin/lazysite-manager-api.pl - token-authenticated (the
same Basic auth). Carries the operations that are not file-shaped. Each is
gated by the matching capability from your grant.
https://SITE/cgi-bin/lazysite-mcp.pl - a remote MCP server exposing the
maintenance operations as tools, for an MCP-capable agent. See
Connection modes.
Two ways to drive a site - pick by what you are. The credential, capabilities and per-file ACLs are identical in both; only the transport differs.
lzs_ token).
whoami, list / read / write /
move / delete files, set_permissions, activate_theme, activate_layout -
as MCP tools. Best for an MCP-capable conversational agent (e.g. a Claude.ai
custom connector, or Claude Desktop/Code): add …/cgi-bin/lazysite-mcp.pl as
a remote connector with bearer auth <partner-id>:<lzs_ token> (the same
credential, colon-joined). It writes one file per call, so for a large initial
build prefer API mode + WebDAV; for ongoing maintenance either works.
…/cgi-bin/lazysite-mcp.pl as a remote custom
connector with the bearer credential. Claude Code / Claude Desktop can use
either: API mode directly (curl the control API, PUT/mount over WebDAV -
best for a full build or bulk upload), or add the same endpoint as a remote
(HTTP) MCP server with an Authorization: Bearer <partner-id>:<lzs_ token>
header for guided, tool-shaped maintenance. A scripted or non-AI client uses
API mode. The credential, capabilities and per-file ACLs are identical
whichever you pick - call whoami first to confirm your grant.
Issue these to the control-API endpoint (/cgi-bin/lazysite-manager-api.pl)
with your access token as HTTP Basic auth, the same as WebDAV. Each is a
?action=<name> query; parameters are passed in the query string unless noted
as a JSON body, and a token client's POSTs need no CSRF token. Call whoami
first to see which your capabilities permit.
whoami (GET)theme-activate / layout-activate (POST)path=<name> - the theme or layout to make active (an empty path
deactivates). Sets theme:/layout: in lazysite.conf and clears the
affected cache in one step. Needs manage_themes / manage_layouts.
cache-invalidate (POST)path=<dir-or-page> - clear generated HTML under that path (use / for the
whole site). Deletes only generated cache (<page>.html with a .md/.url
source), never your author .html partials.
acl-set (POST)path=<file> in the query, plus a JSON body { "read": [...], "write": [...] }
(an operator may also pass "owner"). The first acl-set on a file you can
write records you as owner. Needs webdav. See Own your pages.
acl-get (GET) / acl-remove (POST)path=<file>. acl-get returns the entry; acl-remove clears it (both
owner-only, operators aside). Needs webdav.
config-set (POST)key=<name> and value=<…> (query string or JSON body). Sets one
allowlisted site-config key in lazysite.conf - currently site_name,
site_url, search_default. Privilege-relevant keys (manager groups,
plugins, auth) and ones with their own action (layout/theme - use
layout-activate/theme-activate) are refused. Needs manage_config.
The WebDAV root maps one to one onto the docroot. You address the source .md
file, not the published URL. The page_source value (see
Reference) is exactly the WebDAV path for a page.
Published URL Source file (WebDAV path under /dav/)
/ /index.md
/about /about.md
/docs/install /docs/install.md
/docs/ /docs/index.md
Published URLs are extensionless on the read side; on the write side you always
address the .md (or .url) file.
Your capabilities and path scope come from the brief (and whoami). The content
tree, assets, the layout/theme files under lazysite/layouts/, lazysite/nav.conf,
and a form's dispatch config lazysite/forms/<name>.conf (the last two with
manage_config) are writable within scope. These paths are denied and the
server rejects writes to them:
/cgi-bin//lazysite/lazysite.conf/lazysite/auth//lazysite/forms/smtp.conf, /lazysite/forms/handlers.conf, /lazysite/forms/submissions/lazysite/forms/<name>.conf, is writable with manage_config (it only
names handlers) - see Wiring a form under Tasks.
/lazysite/manager//lazysite/cache/ and /lazysite/logs//lazysite/templates/llms.txt, sitemap.xml, and feeds.
PROPFINDGETPUTMKCOLDELETE, MOVE, COPY/dav does not respondRead the status before concluding the path is wrong - each one is a specific gate, not a missing endpoint:
404 on every method (including OPTIONS)webdav_enabled: yes in lazysite.conf). Ask the operator
to enable it; the path is correct.
403 "WebDAV not enabled for this account"403 "HTTPS required"https://.
401lzs_ token).
.brief sidecarsEvery file you author should carry a sidecar <file>.brief beside it -
index.md.brief next to index.md, main.css.brief next to a theme's
main.css. The brief records why the file exists and what each edit
changed, so the next agent (or the operator) understands intent before
touching it.
A brief is a real spec, not a thin note - the richer it is, the better the owner can steer the page by editing it. Write it in markdown and cover:
forms/enquire.conf").date · action · who · what, one line per edit.Maintain it as you work: PUT the brief when you create the file; on every
later edit GET it, append a log line, and PUT it back (append - never
rewrite the history).
# Brief - index.md
## Purpose
The landing page: convince a visitor to enquire within one screen.
## Sections
1. Hero - the doorway photo + a one-line promise.
2. Highlights strip - three short proof points.
3. Contact CTA - links to /enquire.
## Tone & style
Warm rustic-luxury; British English; no em-dashes; honest, never overstated.
## Images & sources
hero: doorway.jpg. Copy drawn from the owner's "Welcome" document.
## To change this page
e.g. "swap the hero to the garden photo", "drop the highlights strip",
"make the CTA say Book a viewing".
## Log
- 2026-06-23 · created · <you> · initial landing page
- 2026-06-24 · edit · <you> · reworded hero, added contact CTA
The brief is a two-way spec, not just a record - it is how you and the owner collaborate on a page without hand-editing its markup:
.brief in plain language. On your next pass, read the brief, diff
it against the page, and refactor the page to match the brief, then
append a log line. The brief is the source of intent; the .md is its
current rendering - so the editable thing is the brief, and the page follows.Briefs are private: they are denied to public visitors at every layer and
never appear in sitemap.xml or llms.txt. They are reachable only to you
over WebDAV and to the operator in the manager. A .brief is not a blocked
extension, so it writes through your normal content (and theme/layout) scope
exactly like the file it accompanies. Briefs are encouraged, not enforced - a
publish without one still succeeds, but the Files page flags what is missing.
On a shared scope where several authors write, you can own a file so others cannot overwrite it. Ownership and permissions are not files in the content tree - they live in a central store and are set through the control API:
POST .../lazysite-manager-api.pl?action=acl-set&path=/content/about.md
{ "write": ["your-partner-id"], "read": ["your-partner-id"] }
acl-set on a file you can write records you as its owner.write - an allowlist; if present, only the owner and these users may
PUT/DELETE/MOVE the file. Omit to leave writes open (scope still
applies).read - an allowlist for GET over WebDAV. Omit to leave reads open. (This
governs WebDAV/manager access only - the public still sees the rendered
page.)acl-get returns the current entry; acl-remove clears it (owner only - so no
one can take over a file you own). Without an entry, access is just your
account's scope, exactly as before. Usernames only for now (no groups).
The processor serves a cached .html only when it is newer than its .md
source, and regenerates when the cache is missing or stale.
PUT updates the source's mtime, so the page regenerates on its next
request. No separate cache action for ordinary content.
auth: or payment: render per request.
nav.conf, activating a theme/layout, or changing a config key does
not retro-invalidate pages whose cache is already warm. For content scope,
re-PUT the affected pages (a content PUT self-invalidates). For
theme/layout/config, clear the cache via the control API (cache-invalidate)
or ask the operator.
Theme files (lazysite/layouts/<layout>/themes/<theme>/…) need manage_themes;
layout structure and the shared wrapper (layout.tt) need manage_layouts (a
separate capability). And the active layout and theme are read-only over
WebDAV by design - a PUT to the live layout.tt returns 403 regardless of
capability. To re-skin globally:
MKCOL
lazysite/layouts/<new> and …/themes/<theme> (a fresh path returns 409
until its collections exist), then PUT the files.layout: <new> in one page's front matter; the theme
SOURCE css is web-served at /lazysite/layouts/<new>/themes/<theme>/main.css
(the /lazysite-assets/ mirror is 404 until activation).layout-activate / theme-activate, which
set lazysite.conf and clear the cache atomically) - or hand off to the
operator. Then drop the per-page overrides.Capabilities are read from your account on every request (the token does not encode them), so a grant is effective immediately - you do not need a new token.
A 2xx is not proof the page is right. After publishing, confirm:
200.[% … %].nav.conf on every affected page (re-PUT any still
showing the old nav).GET the form's action URL and expect anything but
404; the receiver is a server CGI (/cgi-bin/form-handler.pl) the operator
installs - if it 404s, report it rather than trying to fix it.POST the pairing key to the exchange endpoint; store the token + expiry.PROPFIND /dav/ to confirm, and whoami to read your real grant.PROPFIND or GET the target to check current state..md with valid front matter (see
authoring).MKCOL any missing parent collections, top down.PUT to its docroot-relative path under /dav/. The page regenerates on
next request.PROPFIND the tree to learn what exists.MKCOL collections parent-first; PUT each file (diff against the
PROPFIND and skip unchanged).PROPFIND/GET on a sample, per the checklist above.GET /dav/lazysite/nav.conf, edit, PUT it back.A ::: form block named e.g. enquire needs a dispatch config to receive
submissions. With manage_config you deploy this yourself - no operator step
for file storage:
PUT /dav/lazysite/forms/<name>.conf (matching the form's name), listing
the handlers it dispatches to:
```yaml targets:
local-storage ships by default and writes submissions to
lazysite/forms/submissions/ - nothing else to set up. Email delivery
needs the operator: the SMTP credentials (smtp.conf) and the email
handler in handlers.conf are secrets you cannot write - ask the operator
to configure them, then reference that handler id here.
You may write only the per-form <name>.conf; smtp.conf, handlers.conf,
and the submissions/ store are denied (secrets and data).
expires_at, POST to rotate with your current token as Basic auth.When you cannot reach /dav/ - no egress, no token, or a locked-down runner -
emit the file set you would have published as a single JSON bundle the
operator applies. Same content, no network.
{
"lazysite_bundle": 1,
"post": ["clear-cache"],
"files": [
{ "path": "about.md", "content": "---\ntitle: About\n---\nAbout us.\n" },
{ "path": "lazysite/layouts/dhcf/layout.tt", "content": "…[% content %]…" }
]
}
path is docroot-relative - the same path you would use under /dav/.content is the full file body (JSON-escaped - no delimiters to collide with).post lists post-extract actions; use "clear-cache" when the bundle changes
a theme, layout, nav.conf, or config file (content pages self-invalidate).The operator applies it (auditing first - a manifest, not an auto-run script):
perl tools/lazysite-bundle-apply.pl --docroot DOCROOT bundle.json # dry run
perl tools/lazysite-bundle-apply.pl --docroot DOCROOT --apply bundle.json # write
The tool validates every path against the deny list, confines writes to the docroot, reports create-vs-overwrite per file, and prints the post-extract commands. The file set is identical to a live publish - only the transport differs.