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.
Before planning a sequence of calls, read describe_capabilities. It is the
authoritative list of what this account can call on this channel, and it is
narrower than the list below: some actions are served only to the manager UI over
a cookie session, so a token or MCP client cannot reach them at all. Planning
against the full action list and discovering the subset by being refused is a
trial-and-error loop that one lookup replaces.
?action=regenerate-registries clears sitemap.xml, llms.txt, robots.txt
and the feeds so they rebuild from current content. Use it after deleting or
renaming a page when you want to verify the result: the delete takes effect
immediately but a registry is rebuilt lazily, so reading the sitemap straight
afterwards can still show the old URL and look like the delete failed.
Do not delete a generated registry file to force a refresh. It is not a
cache invalidation - it is an outage that ordinary traffic will not necessarily
clear. A registry is rebuilt during page processing, when its output is
missing or stale. Requesting sitemap.xml does not run the processor, and a
request for a cached page is not a render - so on a settled site with a warm
cache and no editing, nothing notices the file is gone and it can stay 404
indefinitely. This has happened on a live site: sitemap.xml was down for about
a minute and llms.txt for longer.
Some list-shaped responses are worth knowing before you read one: a list
response names its contents, so list_versions returns versions,
list_domains returns domains, form-list returns forms. If you read the
wrong key you get an empty result, which looks exactly like a working call that
found nothing - so check the key before concluding a feature is broken.
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).
Every file you author should carry a brief - the record of why the
file exists and what each edit changed, so the next agent (or the
operator) understands intent before touching it. A brief is not a
sidecar file: it lives out of band in an engine-owned store, keyed by
the content path it describes. Briefs are private at every layer - never
served, never in sitemap.xml or llms.txt - and readable only through
the tools below and the operator's manager.
How to work with briefs:
read_brief { path } and append_brief { path, entry }.brief-read and brief-append (an explicit
path is always required). briefs-list shows every entry - with an
orphan flag for briefs whose content path no longer answers - and
brief-delete clears one, so you can clean up anything you orphan.<file>.brief file. On a site where the briefs
plugin is enabled, that write is refused on every channel, and the
refusal names the replacement. On a site not yet migrated a sidecar
still writes, but the store is the standard: the operator imports
existing sidecars from the Plugin Manager's Migrate sidecars, and
the engine carries store entries through renames and deletes for you.A brief is append-only: the store stamps every entry with the date and your identity, so the history builds itself. Your first append for a file should capture the spec:
forms/enquire.conf").Later appends are log lines - what changed and why, one entry per substantive edit.
A brief can describe any path the site holds - a folder (/docs), an
asset (/favicon.ico), a theme stylesheet, even the site root - not only
Markdown pages. Brief-first authoring is welcome: appending to a path
that does not exist yet records intent for the file you are about to
create.
The brief is a two-way spec, not just a record. Backfill one for every page you maintain, based on what the page currently is. The owner reads and appends through the manager's Brief affordance; when an appended entry asks for a change, refactor the page to match it and append what you did. The brief is the source of intent; the page is its current rendering.
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.Three different things can stop a write, and they call for three different responses. Do not probe to tell them apart - the status says which.
403507500A 507 in particular is not a permission decision. Reading it as one, and
telling the operator that writes to a path are denied by policy, sends them
looking in the wrong place.
All five write verbs answer this way - PUT, DELETE, MKCOL, MOVE and
COPY - so the same reading works whatever you were doing. The body names
which directory, which matters for MOVE: it writes two of them, and a
MOVE can fail because the source directory is unwritable while the
destination is fine. Pass the body along verbatim; it says source or
destination and the operator needs that to know where to look.
MKCOL has one more distinction worth reading. A 409 means the parent
collection does not exist - that one is yours to fix, by creating the
parent first. A 507 from the same request means the parent exists and the
server cannot write into it.
The control API is not callable from a page. It sends no CORS headers and refuses a preflight explicitly, because it serves callers that hold operator-issued credentials and a browser cannot hold one safely.
If you are designing something where a page talks to the site, the answer is a form POST for anything the visitor sends, a static file for the page itself, and a credentialled component - you, over MCP or this API - for the privileged work. See API and raw mode for the reasoning and the alternatives.
When a form is submitted, the site raises a notification of its own accord: into
the manager's notification bell, and - where the operator has configured the
notify-xmpp plugin - as a chat message to them. It names the form and the time
and carries none of the submitted content.
So there is no need to build a polling loop to discover that a questionnaire has been completed, and no need to ask the operator for a webhook. If a workflow needs to react when material arrives, the notification is the trigger that already exists; ask the operator to point it wherever the reacting component listens.
To read what was submitted, use read_form_submissions (MCP) or
form-submissions (control API), both under the least-privilege
read_submissions capability. See Forms.
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.