agentleFS
OpenLinkSoftwareOpenLinkSoftware/ai-agent-skills/weblog-from-webdav/SKILL.md

weblog-from-webdav

Skill42 starsChanged 50 days agoMITView on GitHub

Build, deploy, update, or troubleshoot a Virtuoso Server Pages weblog generated from a Virtuoso WebDAV collection of HTML or Markdown documents. Use for VSP weblog engine setup checks, SQL bootstrap via Virtuoso isql or isql over TLS/WebID, VSP weblog templates, RSS/Atom feed discovery, AtomPub links, scoped full-text search, calendar date filtering, schema:category facets, category metadata application, and post publication via WebDAV copying of HTML, Markdown, and asset folders including mTLS and On-Behalf-Of delegation.

The file

We split this skill into the rules it gives an agent

12 rules. About 0 are common, 3 are distinctive, and 9 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 9

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    When the user asks to generate a weblog from a WebDAV URL, a static HTML index is not a valid completion unless the user explicitly asks …

    line 12
  2. Very specific:

    Look-and-feel is itself a configuration modality when using deploy-weblog-skinned.sql: weblog:skin (classic default, or editorial) resolves …

    line 35
  3. Very specific:

    Before authoring VSP or SQL, search prior memory/work for DAV_RES_UPLOAD_STRSES_INT, string_output, HTTP_PATH, index.vsp, raw <?vsp, …

    line 84
  4. Very specific:

    If the engine is missing or incomplete, switch to isql-engine setup.

    line 86
  5. Very specific:

    Use scoped search. In Virtuoso SQL, keep contains / xcontains as a top-level AND predicate and escape multi-word user input into a valid …

    line 89
  6. Very specific:

    Use calendar controls for date ranges in the sidebar, with from and to parameters preserved in filtered links.

    line 90
  7. Very specific:

    Avoid $1, $2, and similar tokens in SQL loaded by isql.

    line 110
  8. Very specific:

    Do not rely on SET MACRO_SUBSTITUTION OFF unless the target Virtuoso build accepts it.

    line 111
  9. Very specific:

    Use dict_iter_next for category aggregate dictionaries.

    line 113
  10. Distinctive:

    Keep feed links resolvable.

    line 114
  11. Very specific:

    Treat the certificate-bearing identity and the delegated principal as separate facts: -X or --cert identifies the calling agent, while -W …

    line 116
  12. Distinctive:

    Never write credentials into the skill, generated SQL, VSP, logs, or committed files.

    line 118

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Weblog From WebDAV

Use this skill to turn a Virtuoso WebDAV folder into a live weblog whose posts remain ordinary files. The skill always starts by determining whether the Virtuoso-side weblog engine is already in place. If it is not, bootstrap it with isql; WebDAV is then used for the post-publication workflow.

Blocking Gate — WebDAV URL Means VSP/isql Weblog

When the user asks to generate a weblog from a WebDAV URL, a static HTML index is not a valid completion unless the user explicitly asks for a static-only page or preview. The default deliverable is either:

  • a live verified VSP weblog deployed on the target Virtuoso/WebDAV server, or
  • a complete deployable VSP/isql bundle: index.vsp, deploy-*.sql, optional route SQL, optional facet/category SQL when requested, README/run notes outside the skill package, and verification queries.

Before writing or claiming completion, read references/webdav-weblog-engine-gate.md and run scripts/validate_generated_weblog_bundle.py against the generated bundle. If deployment credentials are unavailable, stop at the deployable bundle and state that live deployment is blocked by missing authenticated isql/WebDAV access. Do not substitute a static HTML lens as the answer.

The skill supports two interaction modalities, each with plain credential and TLS/WebID variants:

  • isql engine mode: inspect or create the server-side weblog engine: VSP resource deployment, DAV path mapping assumptions, route/friendly URL setup, SQL helpers, full-text/search support, feed handling, metadata access, ACL/cache maintenance, and category staging. If the SQL listener is TLS-enabled, use isql with -X for the client PKCS#12 bundle, -T for the CA bundle, and -W for delegated WebID identity.
  • WebDAV post mode: publish posts and assets only, by copying HTML files, Markdown files, and associated asset folders into the configured DAV collection. WebDAV can also read or set custom resource properties when the engine already supports them. If acting as a software agent for a principal, pass the principal WebID via the On-Behalf-Of HTTP header.
  • OPAL tool mode: expose server-side weblog operations, such as post pinning, as Virtuoso stored procedures registered with OAI.DBA.REGISTER_CHAT_FUNCTION. Registered functions are available to OPAL/MCP-capable agents and are described through /chat/functions/openapi.yaml.

First decisions

Establish these before editing or deploying:

  • Target DAV collection, for example /DAV/www2.openlinksw.com/data/html/.
  • Public route, for example https://www.openlinksw.com/weblog/.
  • Deployment mode: isql-engine, webdav-posts, or hybrid where SQL bootstraps the engine and WebDAV publishes posts.
  • Identity mode: SQL username/password, WebID-TLS client certificate, WebID-TLS plus delegation, WebDAV username/password, WebDAV mTLS, or WebDAV mTLS plus On-Behalf-Of.
  • Variant: base weblog, OpenLink-site themed, facet-enabled, or the general-purpose, parameterized templates/deploy-weblog-skinned.sql (any DAV collection/route, not fixed to one site).
  • Whether search, calendar date filtering, RSS/Atom/AtomPub links, schema:category facets, and scheduled category refresh are required.
  • Look-and-feel is itself a configuration modality when using deploy-weblog-skinned.sql: weblog:skin (classic default, or editorial) resolves at request time from a ?skin= override, then a weblog:skin collection property, then the deploy-time default — no redeploy needed to switch. See references/skin-authoring-contract.md.
  • Whether the newsletter feature (double opt-in subscribe, batched digest via the operator's own SMTP relay) is wanted: weblog:newsletterEnabled plus templates/register-weblog-newsletter.sql.
  • Every outbound newsletter message (confirm, admin-import activation notice, admin-unsubscribe notice, and both digest sends) carries Message-ID, List-Id, Precedence: bulk, and — when a per-recipient unsubscribe link exists — List-Unsubscribe / List-Unsubscribe-Post (RFC 8058 one-click), built by WEBLOG_NEWSLETTER_BULK_HEADERS. These headers reduce spam-tagging risk but are not sufficient on their own: actual inbox placement is dominated by SPF/DKIM/DMARC alignment on the sending domain and the SMTP relay (weblog:newsletterSmtpServer / the server's default mail server), which this skill has no way to configure from VSP/SQL — that is the operator's mail infrastructure to set up.
  • Email layout: every newsletter message is multipart/alternative (plain text + HTML, each part base64-encoded so no line exceeds SMTP's 998-byte limit), with a Subject RFC 2047-encoded when it contains non-ASCII. The HTML follows the Substack pattern and is built for mail clients, not browsers: a 600px table-layout:fixed container, inline styles throughout (so a post's own stylesheet in full-content mode can't restyle it), a hidden preview line, a masthead linked to the weblog -- the one place the weblog's name appears in the body (the welcome heading is just "Welcome", the footer says "this weblog", and digest bylines omit it; the plain-text part, which has no masthead, keeps the name) -- and per post a serif title, the page's <meta name="description"> as subtitle, a byline row (date · reading time · Read online), the body, and a table-based "Read the full post" button. Confirm / activation / unsubscribe notices render their admin-editable plain-text templates as paragraphs with a button for the primary link. The footer carries Unsubscribe, "Visit the weblog", and — when weblog:newsletterPostalAddress is set on the collection — the sender's postal address (required for commercial mail under CAN-SPAM). A post body cut at the digest's length limit is passed through WEBLOG_NEWSLETTER_CLOSE_OPEN_TAGS, because an element left open by the cut (e.g. a display:none container) otherwise hides every later post and the footer. Gmail clips messages over ~102 KB, which several full-content infographic posts in one digest will exceed; weblog:newsletterContentMode auto/snippet keeps digests small. Logo: every email (confirm, welcome, unsubscribe notice, digest, immediate) shows a logo above the masthead, linked to the weblog, 160px wide, alt text = the sender name. weblog:emailLogoUrl (Email Server Config > Logo image URL): blank = OpenLink's logo https://www.openlinksw.com/images/oplogo_std_200x80.png (200x80 PNG, transparent background -- the raster logo openlinksw.com publishes as its depiction; its header logo is SVG, which Gmail and Outlook do not render in email), none = no logo, otherwise an http(s) image URL (PNG/JPEG/GIF). Verified locally 2026-09-28 for the default, a custom URL, none, an invalid value being rejected, and the digest.
  • weblog:adminEmail (settable from the Email Server Config panel) is the site operator's own contact address, distinct from weblog:newsletterFromAddress (the subscriber-facing sender identity): it is used as Reply-To on every outbound newsletter message, and as the destination for operational alerts (a new confirmed subscriber, a digest batch with failed sends) via WEBLOG_NEWSLETTER_NOTIFY_ADMIN. Leaving it blank disables both, matching this skill's soft-fail-on-missing-config convention.
  • Admin action security: index.vsp's admin_action dispatcher (used by every dashboard form — skin/newsletter config, digest send, import, pinning, etc.) is gated by real HTTP Digest authentication, not a shared-secret token. Because index.vsp itself must stay world-executable to serve the public blog, deploy-weblog-skinned.sql maps a second VHOST — action_route (derived as {public route}-action/, e.g. /weblog-admin-action/) — at the same physical index.vsp, protected declaratively via VHOST_DEFINE's auth_fn=>'DB.DBA.WEBLOG_ADMIN_AUTH_FN'/sec=>'digest'/realm=>.... WEBLOG_ADMIN_AUTH_FN wraps Virtuoso's native DB.DBA.HP_AUTH_SQL_USER builtin (real Digest verification against SQL_USERS) and additionally checks the authenticated username against the collection's weblog:adminDavUser property, so only that collection's configured admin — not just any valid SQL account — passes. The dispatcher itself also rejects any admin_action request that didn't arrive via action_route (checked against http_request_header()[0]), so the same code reachable through the public route can't be used to bypass the gate. dba is always accepted, the same way it bypasses ordinary DAV/SQL grants everywhere else; members of the WEBLOG_OPERATOR role are also accepted. Any account authorized this way must have U_SQL_ENABLE=1 — WEBLOG_ADMIN_AUTH_FN authenticates through DB.DBA.HP_AUTH_SQL_USER, which refuses an SQL-disabled account outright, even with the correct password (a DAV-only account like the built-in dav user, or a content account that was deliberately SQL-disabled, fails here even though it can read the Digest-protected admin dashboard — that check goes through ordinary DAV authentication, a separate mechanism with no such requirement). Every setting the dashboard saves (weblog:skin, weblog:adminEmail, weblog:newsletterConfirmBaseUrl, digest/content mode, per-post schema:category/schema:position, the default-pin seed at deploy time) is written with DB.DBA.DAV_PROP_SET_INT, never with DAV_PROP_SET (..., dav_user, pwd_magic_calc(...), ...) — the VSP always runs as the route's own vsp_user regardless of who authenticated at the HTTP layer, so a write keyed to that account's own SQL password hash silently does nothing whenever that hash doesn't resolve (confirmed live: dba authenticating successfully on the action route still could not make a setting stick, because the VSP was still executing, and still writing, as a different vsp_user). DAV_PROP_SET_INT needs no account credential at all, so it is unaffected by any account's U_SQL_ENABLE state.
  • RES_PERMS can silently reset on upload (blocking correctness requirement): confirmed live on a real instance that DAV_RES_UPLOAD_STRSES_INT's perms argument did not take effect for either index.vsp or dashboard.html — RES_OWNER/RES_GROUP applied correctly, but RES_PERMS came back as a plain default (rw-r--r--, world-readable, no world-execute) instead of the requested value, on the same Virtuoso build (08.03.3335) that applies it correctly locally; the cause was not tracked down. For index.vsp this silently breaks the action route the same way the general restrictive-permissions constraint does (world falls back to read-only, so an authenticated request is served raw source instead of executed) — but here nobody and dba both still get real execution, since owner/group already have rwx/r-x. For dashboard.html a reset is a direct exposure of subscriber names/emails, not just a broken feature. Both uploads now re-assert RES_OWNER/RES_GROUP/RES_PERMS with an explicit UPDATE WS.WS.SYS_DAV_RES immediately after the upload call, so the deploy's own word on permissions is never left to a write whose actual effect isn't being verified. There is no weblog:adminActionToken/shared-secret scheme anymore; a still-present admin_token hidden field on dashboard forms is vestigial (always empty) and does nothing.
  • Admin dashboard location (blocking architectural rule): dashboard.html lists subscriber names and emails, so it must never live in the public blog collection — anything there inherits whatever makes that collection public (on UB, an ACL on /DAV/demos/daas/ left the file anonymously readable despite its own 000 world bits). Ask the blog admin where the admin dashboard should live, and pass it as WEBLOG_DAV_DEPLOY_SKINNED's optional 7th argument, admin_collection. If it is omitted, the location a previous deploy recorded in weblog:adminCollection is reused. Otherwise the suggested default is an aunt/uncle of the blog collection (a sibling of its parent, so it's outside the public parent's subtree too): /DAV/demos/daas/ → /DAV/demos-daas-admin/. The deploy refuses a location inside the blog collection. The admin collection is owned by dav_user and grouped to the WEBLOG_OPERATOR role, with no public read (111111000N; dashboard.html is 110110000N). The permissions are re-applied on every deploy, and any old dashboard.html left in the public collection is deleted. DAV owner/group arguments must be the role's numeric U_ID: passing the role name silently stores group -12. The admin route is defined once, TLS-only, with ppath set to the admin collection, sec=>'SSL', def_page=>'dashboard.vsp', and vsp_user=>dav_user (see VAL sign-in below; dashboard.html itself stays locked). Its vhost comes from the 8th argument, admin_host. When the deploy is driven from a prompt, take admin_host from the host of the blog's public URL (for example https://linkeddata.uriburner.com/weblog/ → linkeddata.uriburner.com); [URIQA] DefaultHost is only the fallback. Its lhost comes from the 9th argument, admin_listener (e.g. ':443'), else [HTTPServer] SSLPort, else an existing :443 listener. Instances whose TLS listener is defined in Conductor (UB) have no SSLPort in the ini. The deploy fails before writing anything if no host or TLS listener can be resolved.
  • WebDAV permalink and Subscribe button: the selected/viewed post (both skins) shows a "WebDAV Permalink" link — the post's raw underlying DAV URL (site_base + weblog:dav_collection + filename), not the weblog's own ?post= query-string view — distinct and separately clickable. The feed-buttons nav gets a third "Subscribe" button (a matching --subscribe color token per skin) whenever weblog:newsletterEnabled is true. The subscribe form itself is a native <dialog id="newsletter"> (.showModal()/.close(), with a close button and a dimmed ::backdrop) opened in place by the Subscribe button — not a #newsletter anchor scroll to a footer section; verified live end-to-end (open, close, and a real subscribe submission creating a pending WEBLOG_SUBSCRIBER row) via a real browser, since <dialog> interactivity can't be checked with curl.
  • Email templates: every outbound email's subject, and (for confirm/activation/unsubscribe-notice, plus digest's optional intro paragraph) body, is admin-editable from the dashboard's Email Templates panel via DB.DBA.WEBLOG_RENDER_EMAIL_TEMPLATE (reads a weblog:emailSubject*/weblog:emailBody*/weblog:emailIntroDigest property, falling back to the exact current hardcoded text when unset, then substitutes {{TOKEN}}s). The digest's per-post-card HTML and the immediate-mode body are generated code, not template-editable — too complex to safely expose as free text. admin_action=set_email_template (template_type, subject, body, or reset=1 to revert) rejects a save that drops a type's required token (e.g. a confirm email missing {{CONFIRM_URL}} would have no way to actually confirm) via DB.DBA.WEBLOG_CHECK_EMAIL_TEMPLATE_TOKENS, checked before anything is written. Verified live: default render, a customized save actually changing what WEBLOG_RENDER_EMAIL_TEMPLATE returns, a missing-required-token save correctly rejected, and reset restoring the default. Welcome email for imported subscribers (activation): placeholders {{NAME}} / {{FIRST_NAME}} ("Subscriber" when the import carried no name), {{EMAIL}}, {{ADMIN_NAME}} (weblog:adminName, the Email Server Config panel's Admin name, else the sender name), {{WEBLOG_TITLE}}, {{WEBLOG_URL}}, {{GREETING}}; {{UNSUBSCRIBE_URL}} is optional because the HTML footer and List-Unsubscribe header always carry the link and the plain-text part gets it appended when the message leaves it out. Each import form (CSV, RDF, Manual Entry) also has an optional "Welcome email for this import" subject/message used for that batch only (welcome_subject/welcome_body -> WEBLOG_NEWSLETTER_IMPORT_CSV/_RDF/_ONE -> WEBLOG_NEWSLETTER_SEND_ACTIVATION's subject_override/body_override); blank falls back to the saved template. Verified locally 2026-09-28 through the real Digest-authenticated admin forms: saved template with a named and an unnamed subscriber, and a per-import override.
  • Every dashboard panel collapses by default: Delivery Settings, Email Server Config, Email Templates, Import Subscribers, Tagging & Scheduling, and Subscribers are each a native <details class="collapsible"> (no open attribute, no JS) — a freshly opened dashboard is a stack of collapsed titles, not a page of forms to scroll past. Email Templates and Tagging & Scheduling additionally nest their own inner <details> (the 6 template types; Post Tags & Pinning / Scheduled Jobs) — verified live that the outer and inner collapsibles toggle independently, since a naive tag-balance mistake across a hand-edited <section>/<details> pair is easy to introduce and easy to miss without actually rendering the result. Subscribers' <summary> carries a live count badge (Subscribers (N)) so the total is visible without expanding it.
  • Subscriber actions must refresh the dashboard too: dashboard.html only regenerates when an admin action runs, a scheduled auto-refresh fires, or the site is (re)deployed — a subscriber confirming or unsubscribing through the public route (WEBLOG_NEWSLETTER_SUBSCRIBE/CONFIRM/UNSUBSCRIBE) is none of those. Confirmed live: a subscriber who genuinely confirmed hours earlier, with no auto-refresh schedule configured, still showed as 0 subscribers on the dashboard. All three now call WEBLOG_NEWSLETTER_TRY_DASHBOARD_REFRESH (best-effort, never blocks the subscriber-facing action if it fails or the dashboard proc isn't installed) right after the WEBLOG_SUBSCRIBER row changes.
  • Upgrades must never be destructive (blocking correctness requirement): WEBLOG_DAV_DEPLOY_SKINNED refuses to overwrite an existing index.vsp unless its current content contains the literal string multi-skin, config-driven (present in every index.vsp this template has ever generated, unmodified by any {{TOKEN}} substitution) — verified live this correctly blocks overwriting a different, hand-authored template (e.g. deploy-weblog-opl-site*.sql) while still allowing a normal redeploy over an existing deploy-weblog-skinned.sql site. The 12th argument, allow_template_overwrite (default 0), is the explicit, deliberate opt-in to bypass this and replace a non-matching index.vsp anyway. A skin change on an already-deployed site never needs a redeploy at all — weblog:skin (the admin dashboard's Skin setting) is resolved at request time — so this default only ever blocks something that shouldn't have been attempted in the first place.
  • site_base (scheme://host) — two bugs fixed together, both confirmed live: it silently resolved to empty (the 4-arg http_request_header(lines, 'Host', '', '') keyword-lookup call never actually found the Host header despite it genuinely being in the raw lines — a plain scan for a line starting Host: is what actually works), and the scheme was hardcoded to http:// regardless of the real request (is_https_ctx(), the same native builtin OAUTH2.DBA.check_https_ctx wraps, correctly reports which). This silently made every RSS/Atom <link>/<guid> relative instead of absolute — fixed as a side effect of building the permalink, not just for the permalink itself. A trailing CR/LF on the raw header line also survives plain trim() (it only strips spaces) — strip chr(13)/chr(10) explicitly or every URL built from site_base gets a line break spliced into it.
  • Nested-VSP-string comment trap: a comment inside the deploy proc's embedded index_content string that types out \r\n as literal backslash-r-backslash-n gets escape-interpreted into a REAL CR+LF at the outer deploy script's own compile time (confirmed live) — splitting that one comment across physical lines in the generated index.vsp, and the continuation line loses its -- prefix, becoming uncommented (broken) VSP source. Never write literal \r/\n in a comment inside this string; describe the bytes in words instead.
  • Tagline link and help tooltip: weblog_tagline is plain text, reused as-is in RSS <description> and <meta name="description">. The visible masthead span is built at deploy time as finished HTML ({{TAGLINE_HTML}}), escaped with plain replace() so its raw UTF-8 bytes pass through — never sprintf('%V', ...) at request time, which re-encodes a narrow UTF-8 string as Latin-1 and garbles e.g. an em dash (the same double-encoding that garbled post titles), and which would escape an embedded <a> into literal text (confirmed live). To add a real link after the tagline (the pattern the deploy-weblog-opl-site*.sql sites use, e.g. "...a Weblog view of <a href="...">WebDAV Folder</a>"), pass tagline_link_url/tagline_link_text (10th/11th). To give one phrase a help tooltip — the masthead guardrail's "Data Spaces" → "databases, knowledge bases, filesystems, and APIs" — pass tagline_help_term/tagline_help_text (13th/14th); the first occurrence of the term is wrapped in <span class="term-help" title="...">. RSS and the meta description are unaffected by either. Each deploy records these on the collection, so WEBLOG_UPGRADE reuses them.
  • Migrating a site off a hand-built template (e.g. the openlinksw.com weblog, which ran deploy-weblog-opl-site-facet.sql): the same upgrade.sql run, with allow_template_overwrite set to 1 in its TARGET block. Its masthead (title, tagline, tooltip, link) is read from the old page, posts, schema:category facets, schema:position pins and the existing ?post=/?q=/?category=/?feed= URLs carry over, and every later upgrade is an ordinary run with the opt-in back at 0.
  • Routes on a database-defined TLS listener must be SSL (blocking correctness requirement): at every startup Virtuoso's built-in DB.DBA.VHOST_UPGRADE logs "Wrong record in virtual hosts config, operator attention required." for any HTTP_PATH row on :443 that is not HP_SECURITY='SSL' or lacks https_key in HP_AUTH_OPTIONS. A :443 listener set up in Conductor (www.openlinksw.com, UB) is such a listener, unlike the ini-defined *sslini* one (which the check skips -- why local testing missed it). The deploy now reads the listener's own TLS options (WEBLOG_LISTENER_TLS_OPTS) and gives every route it defines there -- public, -action, -admin -- sec=>'SSL' plus those options; /weblog-action had been sec=>'digest' on www's :443 (reported 2026-09-29). WEBLOG_LISTENER_TLS_OPTS treats a listener as TLS only when SYS_HTTP_LISTENERS.HL_TYPE = 1 (Virtuoso's own record; 0 = plain). Reading TLS options from any SSL route was wrong: www's plain :80 listener has a route marked SSL with TLS options, so the deploy tried to make :80 an SSL listener, failed with SR197 partway (public route removed, admin route never defined -> /weblog-admin/ 404), 2026-09-29. Reproduced locally by editing a plain listener's route row to SSL + key; fixed, then re-deployed cleanly. WEBLOG_ADMIN_AUTH_FN now calls HTTP_AUTH_CHECK_USER (realm, 1, 1, null, 'digest') so admin logins stay Digest-only on an SSL-marked route (the scheme otherwise follows the route's security level, and SSL would also accept Basic). Verified locally on a temporary database-defined TLS listener: 0 rows the startup check would flag, public page 200, action route 401 Digest-only challenge, Basic rejected, Digest admin action and dashboard OK, plain-HTTP action route unchanged.
  • VAL sign-in for the dashboard and admin actions: when VAL (Virtuoso Authentication Layer) is installed, the admin gate uses it, so admins sign in with whatever VAL offers on that instance (SQL login, WebID-TLS / client certificate, OpenID Connect, OAuth, ...) and the weblog never handles a password itself. Pattern from the VAL VSP Authentication Tutorial. WEBLOG_VAL_ADMIN_CHECK (coll) calls VAL.DBA.get_authentication_details_for_connection and allows: a real SQL account that WEBLOG_ADMIN_IS_OPERATOR accepts (dba, weblog:adminDavUser, WEBLOG_OPERATOR members) or that VAL.DBA.is_admin_user counts as an instance admin; or any identity granted oplacl:Write on urn:virtuoso:access:weblog:{DAV collection} (WEBLOG_ADMIN_RESOURCE) by a VAL ACL rule in scope urn:virtuoso:val:scopes:weblog (the deploy defines the scope, with no default access; checked with honorScopeState=>0 so a disabled scope can never fall back to default access). A WebID, an OIDC/OAuth account or a VAL group can therefore administer a weblog without a SQL account. The admin route now serves dashboard.vsp (owned by dav — DAV serves a .vsp owned by another account as raw source), which calls WEBLOG_ADMIN_DASHBOARD_PAGE: signed in and allowed → the locked dashboard.html, with a "Signed in as … · Sign out" bar; a browser not signed in, or signed in as someone not allowed → 302 to /val/authenticate.vsp (returnto, res, reslabel, and deniedServiceId for "sign in as someone else"); a non-browser client or any request carrying an Authorization header → HTTP Digest as before. WEBLOG_ADMIN_AUTH_FN on the action route checks VAL first (no Authorization header), then Digest; a signed-in but unauthorized identity gets 403. Because a session cookie and a client certificate are both sent by the browser automatically, a VAL-authenticated action must carry the dashboard's form token (admin_token, stamped per identity by WEBLOG_ADMIN_CSRF from a registry-only secret), else 400; Digest-authenticated actions (scripts) don't need it. Without VAL everything is Digest-only, unchanged. Grant a WebID admin rights with (as dba): select VAL.DBA.acl_rule_new (serviceId=>VAL.DBA.user_personal_uri ('dba'), subject=>'urn:virtuoso:access:weblog:/DAV/…/', agent=>'{WebID}', access=>vector (VAL.DBA.oplacl_iri ('Write')), realm=>VAL.DBA.get_default_realm (), scope=>'urn:virtuoso:val:scopes:weblog', label=>'…');. WebID-TLS needs a TLS listener that requests a client certificate (https_verify 3 in its auth_opts, or X509ClientVerify in the ini). Verified locally 2026-09-29: dashboard — anonymous browser → VAL login page, script → Digest 401, Digest dba 200, VAL session dba 200 with bar and stamped tokens, VAL session of a non-admin SQL user → login page with deniedServiceId; WebID-TLS on a temporary https_verify 3 listener → refused (WebID named as denied) until an ACL rule granted it Write, then 200; action route — anonymous 401, Digest dba OK without a token, VAL dba OK with its token, 400 without or with a wrong one, non-admin 403, WebID + token OK, WebID without token 400; public route still refuses admin actions; dashboard.html via /DAV still 401; full upgrade.sql on two local weblogs with 0 errors; non-dba owner (kidehen) dashboard OK. VAL auto-creates its standard "Access to Profile Graph" rule for a WebID the first time it authenticates.
  • Route loops skip /DAV rows with an empty HP_LISTEN_HOST: VHOST_REMOVE cannot delete a row whose lhost/vhost are empty, so redefining a route there on redeploy always fails with SR197: Non unique primary key on DB.DBA.HTTP_PATH.
  • Subscriber email uniqueness: WEBLOG_SUBSCRIBER_UQ, a unique index on (WS_DAV_COLLECTION, WS_EMAIL), is the actual duplicate guard — but it compares the literal stored string, so without canonicalization Alice@x.com and alice@x.com would pass it as two different rows for the same real address. Every write path (WEBLOG_NEWSLETTER_SUBSCRIBE, WEBLOG_NEWSLETTER_IMPORT_ONE, and the dashboard's manual-entry handler, which checks status directly rather than going through IMPORT_ONE) lowercases the email before it is ever compared or stored, so the existing index enforces true case-insensitive uniqueness without a schema change. The manual-entry handler reports success only when every row it processed was actually added; any invalid address or already-confirmed duplicate is named individually in an error, never folded into a vague "N skipped" count.
  • Blocking correctness requirement: the nl_action=confirm and nl_action=unsubscribe links emailed to subscribers must never change subscription state on a plain GET request — only on POST. Mail-security gateways routinely prefetch every link found in an email with a GET to scan for phishing; if GET executed the action, that prefetch silently confirms or unsubscribes the real subscriber before they ever open the message (this happened live during development: adding List-Unsubscribe caused exactly this). index.vsp's newsletter dispatcher checks the raw request line from http_request_header()[0] (... like 'POST %') and, on GET, renders a confirmation page with a same-URL POST form instead of acting — a real POST (the human's click-through, or a mail client's List-Unsubscribe-Post one-click) is what actually performs the change. Do not "simplify" this back to a single GET-triggered action.
  • Metadata source for categories: existing WebDAV custom properties, an analysis TSV, SQL staging rows, or generated suggestions requiring user approval.
  • Pinning policy: pinning is part of the core engine. On deployment, if no non-zero schema:position exists in the weblog collection, the current first post by recency is seeded as the default pinned item. The pinning tool maintains one pinned post per collection by clearing sibling schema:position values before setting the requested post. Manual metadata can still create multiple pinned posts, but the skill should treat that as drift to clean up.

Initial setup and upgrading an existing deployment

For deploy-weblog-skinned.sql deployments (the general-purpose, parameterized variant), both the FIRST deploy of a collection and an UPGRADE of one already running an older version of this skill's templates go through the same two files:

  • templates/register-weblog-newsletter.sql — newsletter tables/procedures.
  • templates/deploy-weblog-skinned.sql — the engine procedures, including DB.DBA.WEBLOG_DAV_DEPLOY_SKINNED, the actual deploy/redeploy call.

Both are idempotent installers: each DROPs then recreates its own procedures, so reinstalling either one is always safe to repeat. WEBLOG_DAV_DEPLOY_SKINNED itself is also safe to re-run against an already-deployed collection — it reads and preserves existing weblog:* collection properties (skin choice, newsletter config, admin email, etc.) and only refreshes index.vsp/dashboard.html, so redeploying an existing site picks up every template change (new admin panels, header/deliverability fixes, UI redesigns) with zero configuration loss. The one non-idempotent part is register-weblog-newsletter.sql's WEBLOG_SUBSCRIBER table (DROP+CREATE, wiping subscribers on every reinstall) — templates/upgrade.sql (below) backs this up automatically; running register-weblog-newsletter.sql directly, on its own, does not.

templates/upgrade.sql is a single-file convenience wrapper — a concatenation of both templates above, automatic pre-flight backups, and one upgrade call — for handing to an operator who applies it via their own isql session. It is the one file for both a fresh install and every upgrade. The only thing to edit is the TARGET block at the top: the weblog's DAV collection, the host of its public URL, and its path (plus, optionally, the masthead -- title, tagline, tagline link and help tooltip -- which a fresh install should fill in and an upgrade leaves blank to keep what the weblog has; a given value always wins, so the same fields also change an existing masthead; a collection that does not exist yet is created) (host and path separately, because a literal https://host/path in an isql-loaded file can trigger isql's macro substitution). Before Section 1 drops WEBLOG_SUBSCRIBER, its current contents (if any) are copied into a fresh, timestamped WEBLOG_SUBSCRIBER_BACKUP_<timestamp> table and restored right after; before the upgrade step overwrites index.vsp/dashboard.html, their current content is copied into DB.DBA.WEBLOG_UPGRADE_BACKUP. Neither backup table is ever dropped by a later run, and restore steps are documented in-line. This makes "can I revert if it fails" true by default for what this file changes — it is not a substitute for backing up anything else on the target instance.

Before the REDEPLOY block, TMP_WEBLOG_UPGRADE_GRANT_ROLE sets up a WEBLOG_OPERATOR SQL role and grants it EXECUTE on every DB.DBA.WEBLOG_* procedure this skill installs, then grants that role to the target weblog's own recorded admin user (weblog:adminDavUser; skipped for dba or on a first deploy) and to dba. dba is the SQL superuser, but DAV enforces the dashboard file's owner/group/world bits for whoever logs in over HTTP, dba included: with dashboard.html owned by another admin (UB: kidehen, 110110000N) dba got 403 Forbidden at /…-admin/ until it was a WEBLOG_OPERATOR member (verified locally 2026-09-28: 403 → 200 → 403 on grant/revoke). This exists because Virtuoso does not auto-grant execute on a new object to anyone but its owner/dba — verified live 2026-09-23 that a non-dba, non-owner session (a WebID-TLS connection mapped to a restricted account) gets SR186:SECURITY: No permission to execute procedure calling any of these by default. Idempotent and safe to re-run: the role is created once and reused, every matching procedure is re-granted on each run (so a procedure a future template change adds gets covered automatically), and GRANT <role> TO <user> — unlike GRANT EXECUTE ON <object> — is not idempotent on its own (a second grant to an existing member errors with U0013), so the script checks __SQL_MESSAGE for that specific case rather than treating any grant failure as fatal. Add another designated user with grant WEBLOG_OPERATOR to <username>;, run once as dba.

The file ends by calling DB.DBA.WEBLOG_UPGRADE (dav_collection, public_url, allow_template_overwrite := 0, dry_run := 0, admin_collection := null, weblog_title, weblog_tagline, tagline_link_url, tagline_link_text, tagline_help_term, tagline_help_text) (the last six optional; value sources in precedence order: given > recorded > current index.vsp > default) — the single upgrade entry point, also callable directly (select DB.DBA.WEBLOG_UPGRADE ('/DAV/demos/daas/', 'https://linkeddata.uriburner.com/weblog/');). It needs only the collection and public URL; everything else is looked up: route and host from the URL; title, tagline, tagline link and help tooltip, and admin host from the weblog:* properties every deploy now records on the collection (weblog:title, weblog:tagline, weblog:taglineLinkUrl/Text, weblog:taglineHelpTerm/Text, weblog:adminHost) — or, for a collection deployed before those were recorded or by a hand-built template, read from its current index.vsp by WEBLOG_UPGRADE_SCAN_INDEX (this template's <title><?= '…' ?>/<meta name="description">/tagline span, or a hand-built page's literal <title> and <span class="tagline"> including its term-help tooltip and trailing link); admin user, admin collection and skin from weblog:adminDavUser/weblog:adminCollection/weblog:skin, else dba, the deploy's default location, and classic. It returns a JSON report of every value used and where each came from (recorded / current index.vsp / default); dry_run => 1 returns that plan without changing anything. allow_template_overwrite => 1 is the one deliberate yes, needed only to replace an index.vsp this template did not generate — without it the call refuses with a message naming the fix. There is no per-site registry: a new site is upgraded the same way as an existing one. Rehearsed locally 2026-09-27: dry run, refusal, and migration of a hand-built openlinksw.com-style page (masthead, em dash, tooltip, link, posts, pin and facets all carried over; every value recorded on the next run), plus a no-change upgrade of an existing skinned weblog whose page was identical before and after.

This file is a dated snapshot, not a maintained source of truth: regenerate it (concatenate the two source templates plus the TARGET, backup, role-grant and upgrade blocks) whenever either source template changes materially, rather than trusting a stale copy indefinitely.

CAUTION when writing CREATE TABLE ... AS SELECT in Virtuoso SQL: verified live 2026-09-23 that it copies structure ONLY, not rows — a bare CTAS silently produces an empty "backup". Always follow it with an explicit INSERT ... SELECT to actually copy data, as templates/upgrade.sql's subscriber-backup step does. Also, a stored procedure body cannot directly INSERT/SELECT against a table that does not exist yet at compile time (Virtuoso resolves table references at procedure-compile time, not deferred to call time) — a table a procedure will write to must already exist before that CREATE PROCEDURE statement runs, even behind a runtime existence check.

When this skill is used to check or upgrade a remote, already-deployed collection, first fetch the public route read-only (HTTP GET, no credentials needed) to identify which template generation it's running — the presence/absence of class="theme-switch" vs the older class="theme-toggle", a /weblog-admin/ dashboard route, Subscribe/newsletter markup, facet support — before assuming credentials or a redeploy are needed.

Workflow

  1. Inspect the target collection or supplied SQL/VSP artifact before making assumptions. Exclude ._*, .DS_Store, hidden macOS sidecar files, and non-post assets from post enumeration.
  2. Before authoring VSP or SQL, search prior memory/work for DAV_RES_UPLOAD_STRSES_INT, string_output, HTTP_PATH, index.vsp, raw <?vsp, vsp_user, and weblog-from-webdav; reuse proven patterns and failure fixes.
  3. Determine whether the weblog engine is already in place. Check that the target collection has an executable VSP entry point, working feed modes, collection-scoped search, date filtering, optional metadata facets, and the expected public route.
  4. If the engine is missing or incomplete, switch to isql-engine setup. Start from the templates in templates/, patch paths, public URLs, title, and theme labels explicitly, then produce and, when credentials are available, deploy the complete VSP/isql bundle.
  5. Validate the generated bundle with scripts/validate_generated_weblog_bundle.py. This gate is blocking for WebDAV weblog generation.
  6. Use WebDAV only for post publication after the engine check passes: copy HTML files, Markdown files, and associated asset folders. For normal post uploads, use scripts/publish_with_metadata.py so each uploaded document also receives verified schema:category metadata through PROPPATCH. For out-of-band DAV uploads through mounted folders or third-party clients, install templates/register-category-refresh-scheduler.sql and schedule DB.DBA.WEBLOG_DAV_REFRESH_CATEGORIES with a default five-minute interval.
  7. Use scoped search. In Virtuoso SQL, keep contains / xcontains as a top-level AND predicate and escape multi-word user input into a valid free-text expression.
  8. Use calendar controls for date ranges in the sidebar, with from and to parameters preserved in filtered links.
  9. Show category facets only when schema:category metadata exists. Facet counts must be computed from the filtered candidate set, not from all resources after a later display filter.
  10. Support core and prompt-driven post pinning for a designated weblog. The engine deploy SQL seeds the newest valid post as the default pinned item only when no explicit pin exists. For user-requested changes, resolve the weblog target, verify the post resource exists, set schema:position to 1 for the designated post while clearing sibling pins, or reset it to 0 for unpinning, then verify the weblog order.
  11. For agent-facing pinning, deploy templates/register-weblog-pinning-tool.sql through isql, register DB.DBA.WEBLOG_DAV_SET_PIN, and verify it appears in OAI.DBA.LIST_CHAT_FUNCTIONS() and the generated OpenAPI description at /chat/functions/openapi.yaml.
  12. Preserve the recency-ordered post list in the sidebar, with pinned posts promoted ahead of ordinary recency and the first pinned post selected as the default current post. Pinned posts must have an obvious but restrained visual indicator: a compact red-headed pin badge in the sidebar and a non-overlapping status strip above embedded posts or a compact kicker for non-embedded posts. Facets, explicit post selection, and search refine that list; they do not replace it with monthly buckets.
  13. Validate locally when possible, then verify the served route, feeds, search, date filters, facets, pinned ordering, registered OPAL tools, and a newly copied post.

Mode references

  • For engine checks and SQL bootstrap, read references/isql-mode.md.
  • For post publication by WebDAV copy, read references/webdav-mode.md.
  • For template behavior and known VSP pitfalls, read references/vsp-template-contract.md.
  • For custom metadata and category facets, read references/facet-metadata-contract.md.
  • For OPAL/OpenAPI/MCP tool exposure, read references/opal-tool-mode.md.
  • For the config-driven skin mechanism and how to add a new skin, read references/skin-authoring-contract.md.
  • For initial setup or upgrading an existing deploy-weblog-skinned.sql deployment, see "Initial setup and upgrading an existing deployment" above and templates/upgrade.sql.

Practical guardrails

  • DAV_RES_UPLOAD_STRSES_INT needs a string session object such as string_output(), not a string literal passed to a reference parameter.
  • Avoid $1, $2, and similar tokens in SQL loaded by isql; they can trigger macro substitution. Use named variables instead.
  • Do not rely on SET MACRO_SUBSTITUTION OFF unless the target Virtuoso build accepts it; write scripts that avoid macro-looking text.
  • Cast WebDAV property values before trim; Virtuoso can return integers for absent or encoded property states.
  • Use dict_iter_next for category aggregate dictionaries; fragile key-list plus second lookup patterns can produce all-zero counts.
  • Keep feed links resolvable. POSH discovery and visible RSS/Atom buttons should point at actual feed variants handled by the VSP route.
  • In the weblog masthead/footer, use the positioning line Showcasing the power of loosely coupling Linked Data, AI Agents, Skills, and Data Spaces - a Weblog view of WebDAV Folder, with WebDAV Folder as the linked destination-generic anchor text. Wrap Data Spaces in a help affordance whose help text reads databases, knowledge bases, filesystems, and APIs.
  • Treat the certificate-bearing identity and the delegated principal as separate facts: -X or --cert identifies the calling agent, while -W or On-Behalf-Of identifies the WebID principal whose ACL rights should be evaluated.
  • Weblog post iframes must tolerate opaque or sandboxed embedded content: frame helpers should catch access denial, skip unreadable frames, and route same-origin WebDAV collection links to top-level navigation when readable.
  • Never write credentials into the skill, generated SQL, VSP, logs, or committed files.
  • A .vsp resource only executes when WS.WS.SYS_DAV_RES.RES_OWNER is the dav user (uid 2); any other owner serves it as raw static source with no error. Always upload with DB.DBA.DAV_RES_UPLOAD_STRSES_INT(path, stream, mime, perms, 'dav', 'dav', ...) regardless of which account is deploying. See agent-rdf-memory/howto/osdi-vsp-execution-registration.ttl.
  • A VHOST route with is_brws=>0 and def_page dispatches every path under it to that default page, including a post's own filename — there is no way to reach an individual DAV resource directly through that route. Serve raw post content through a query param on the same route (?raw=<filename>, validated against an enumerated resource) instead of assuming a nested path or a second VHOST mapping will reach the file; a sibling is_brws=>1 VHOST hit a 401 in testing and a nested path is captured by the parent route's own catch-all.
  • A POST to a nested .vsp path under an is_brws=>0 VHOST route gets redirected (trailing slash appended), which silently downgrades the request to GET and drops the form body. Route form submissions through the collection's existing index.vsp via a query param instead of a dedicated endpoint file.
  • Remote uploads and removals use HTTP first, then WebDAV, then iSQL, with the principal PKCS#12 path and the uriburner-p12 Keychain item as the sole passphrase source (howto/remote-webdav-upload.ttl, howto/remote-resource-removal.ttl, prefs Steps 215–216). If Keychain access fails, stop and ask the user to resolve it; never use a temporary plaintext credential file. URIBurner operations use port 5443. Verify PUT and DELETE against the public URL and :5443 before claiming success.

Safe Virtuoso ISQL Error Output

Whenever this workflow invokes OpenLink's native Virtuoso isql client, pass the bare client option COMMAND_TEXT_ON_ERROR=OFF, for example isql {host}:{port} {user} {password} COMMAND_TEXT_ON_ERROR=OFF {script.sql}. Place it before SQL input, script arguments, or -i. OFF maps to the client's internal value 0 and prevents the client from appending the full failed command to error output. The client accepts OFF/ON; literal 0 is rejected. Do not use -u for this setting (-u populates the U argument array). This does not redact secrets from server diagnostics, shell history, or process arguments, so keep credentials out of SQL text and logs. Do not apply this Virtuoso-specific option to unixODBC/iODBC isql clients.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.