webcake-landing — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited webcake-landing (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Workflow + rules for using the webcake-landing MCP to build a complete Webcakepage_sourcefrom a brief, and to edit existing pages. The same rules are served at runtime via the serverinstructions(src/domains/landing/instructions.ts) — this skill is the long form.
Reference/validation (no backend/env needed): get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, layout (exact centering/row/grid/stack coordinates for BOTH breakpoints — call it instead of hand-computing left/top; row/grid auto-stack on mobile).
Media (works out of the box; no Webcake credentials required for either tool): search_images — real stock photos, ONLY for image slots with NO source image (nothing supplied by the user, nothing in the reference HTML/URL); returns hotlinkable URLs (src.large hero, src.medium card) to drop into an image element's specials.src. Works out of the box via a shared proxy; optional own key via PEXELS_API_KEY env / x-pexels-key header — free at https://www.pexels.com/api/. On ok:false (or no fitting photo) → find a real image yourself via web search/fetch and re-host it with upload_images; https://placehold.co/<w>x<h> is the LAST resort only after both fail.
get_icon_svg — resolve icon-font NAMES (ms:<name> Material Symbols, fa:<name> Font Awesome — the block.icon values from ingest, or a bare/Iconify name) into real inline <svg> via the Iconify API, so a clone reproduces a reference's icons (esp. Google Stitch, which uses an icon-font class) exactly and self-contained. Material Symbols resolve to the OUTLINED variant (filled fallback). Render each svg as Webcake's native icon — a rectangle: put it in BOTH responsive.{desktop,mobile}.config.svgMask, set styles.background to the icon color, and keep the box SQUARE (the svg is a MASK so it's BLANK without a background and stretched if the box isn't square; validate_page warns on each). No creds. upload_images — converts external image URLs (from ingest_html/ingest_url results), data: URIs, or local file paths from the user's machine (/abs/path.jpg, ~/Pictures/logo.png, file:///…, C:\…) into Webcake-hosted URLs (statics.pancake.vn) for use in specials.src. Pass local paths directly in urls — NEVER upload a user's local file to a third-party host (catbox, imgur, transfer.sh…) to obtain a URL first. Reference images are the user's assets: use this for BOTH intents (adapt AND clone) whenever the page is built from a reference HTML/URL, whenever the user provides image URLs, or whenever the user provides local image files — never swap them for stock photos. Uses multipart upload (200 MB backend limit). Batch: up to 20 entries/call in parallel. No Webcake creds needed. FILES INTO THE USER'S MEDIA COLLECTION ("bộ sưu tập"): with Webcake creds AND a resolved org it uploads through the editor's own media-picker route, which creates the Asset record so the image is re-pickable in the editor (each images[<original>] reports collection:true + asset_id; top level adds collection_uploads / collection_org_id / collection_note). THE ORG IS REQUIRED for a collection upload and must be the SAME org the page is created in — settle it during INTAKE, before any image work (see the workflow below), never mid-build. organization_id explicit / WEBCAKE_ORG_ID / x-webcake-org-id win; exactly ONE org auto-selects; with 2+ orgs and none chosen the call returns ok:false + reason:"organization_required" and the org list — re-call with organization_id (same contract as create_page; it no longer falls back to a personal collection). Only a MISSING JWT falls back to the public CDN endpoint (collection:false) exactly as before — URLs work and the page renders, there's just no Asset row. Optional in_folder (a specific sub-folder; overrides the resolved org root; ignored on the public fallback). UPLOADS BY DEFAULT (dry_run defaults to false): wait for the images map, then put each images[<original>].url into every element that used <original>. NOTE: for reference/web image URLs you usually DON'T need this — the save itself auto-hosts any external image URL left in the source, taking the same collection path (see below). The cases that still need upload_images are a user's LOCAL FILE PATH (the save can't read local files) and filing into a specific collection folder via in_folder. Pass dry_run:true only for an explicit no-op preview (local paths: reports file existence and size).
render_preview — screenshot a page (page_id → its /preview/<id>) or any public url to a PNG you can SEE, so you compare the build to the reference and fix what's off (the fidelity loop: build → render_preview → eyeball the diff → patch_page the lagging element by id → re-publish → render_preview again). full_page defaults to true. Prefer your OWN screenshot ability when you have one (a browser/screenshot MCP such as chrome-devtools, or any tool that captures a URL) — screenshot the preview_url yourself; it's fresh and unlimited. This tool is the fallback for when you have no such ability. Zero-config via Microlink's free tier, which is rate-limited ~50/day PER IP, so on ok:false with quota_exhausted/HTTP 429 just SKIP the visual check that round (don't block the build); a host can set RENDER_SCREENSHOT_BASE (keyed proxy) or MICROLINK_API_KEY for more. A no-domain /preview/<id> only renders ~10 min after the last publish — call it right after create_page/publish_page, and re-publish before re-checking a stale page. TALL PAGES: pass tiles:true to get the page as a stack of top→bottom band images (each readable in detail) instead of one full-page image squished small — needs a Playwright host (RENDER_SCREENSHOT_BASE), falls back to a single image otherwise. No creds.
Auto-host on save: create_page / update_page / add_section / patch_page scan the source for external image URLs (specials.src, url(...) backgrounds, gallery item.link, video poster) and re-host each to the Webcake CDN (statics.pancake.vn) BEFORE storing, rewriting them in-tree; the response's rehost field reports { candidates, rehosted, failed, skipped, collection, collection_org_id }. These uploads take the same collection path as upload_images, which needs a JWT AND a resolved org: only then do they land in the user's media collection (rehost.collection:true, collection_org_id naming the org) and become re-pickable in the editor — org explicit, else auto-selected when the account has exactly one. With no org resolvable the images take the public CDN endpoint (collection:false) — the rehost never blocks a save over images. One more reason to settle the org during intake: the same org must go to both create_page and upload_images. Cached + deduped (a URL reused across elements/saves uploads once) and resilient (a failed URL keeps its original; the save never blocks). So for a clone you just copy each reference src through verbatim — the placeholder-fallback failure mode (model forgetting to upload) is gone. The only thing it can't recover is a URL you already replaced with a placeholder, so don't. Local paths only work in stdio mode; rejected per-entry on the remote HTTP transport.
Inline base64 images are auto-uploaded too — but never AUTHOR them. A data:image/…;base64,… URI anywhere in the source (specials.src, a url(...) background, gallery item.link, video poster, settings.favicon/extra_css/bhet/bbet) is a re-host candidate like any remote URL: the save decodes the payload (no fetch), uploads it to the CDN on the same collection path, and rewrites the slot in-tree — so base64 that arrived from a reference HTML or a user paste needs NO manual fix, just save it; it shows up in the response's rehost counts. validate_page only warns (blocking would stop the save that fixes it). What the warning is really telling you: do not WRITE base64 into a source yourself — the whole image crosses the MCP connection inside your payload and megabytes there can drop it. Use a real http(s) URL, or upload_images (it accepts data: URIs) and the returned statics.pancake.vn URL. A tiny non-base64 data:image/svg+xml;utf8,<svg…> is left alone. If the upload fails (no creds/network) the URI stays, same as a failed remote re-host.
Reference ingest (no env needed) — turn an EXISTING page into a layout anchor: ingest_html(html, intent?, detail?) / ingest_url(url, intent?, detail?) — parse HTML or fetch a URL into a reference AST (title, description, sections classified by role — hero/features/form/cta/footer/… — with headings, CTAs, images, form fields, top colors + fonts from BOTH inline styles and <style> blocks, a token-named palette + design_tokens, background_images from stylesheets, and a per-section size_hint = { height, basis, css? } — the desktop section height in px (from explicit source CSS when present, else a content-volume estimate); set each rebuilt section's desktop height from it instead of the 800px default, then redo the mobile height per the mobile text math). detail:'compact' (default, ~2-5 KB) gives backward-compatible layout hints. detail:'full' (~up to 25 KB) adds: per-section blocks (repeating card/tile/step structures: title, body, image, cta), lists (li items), gradients, images as { src, alt } objects, and widgets = { hint, html, css? } — the cleaned source HTML + matching CSS of composite visuals (phone/device mockup, chat thread, dashboard, browser frame); build the html-box FROM that html verbatim with the css inlined, never re-imagine the widget's markup — use for clone-faithful rebuilds. Default intent='adapt' (rewrite the TEXT for the user's brand); intent='clone' only when the user explicitly asks. For BOTH intents: re-host image URLs found in the AST (images, background_images, og_image) via upload_images and reuse them in the matching slots — adapt rewrites text, not imagery; search_images only fills slots with no source image. Tailwind-CDN pages (Google Stitch output) keep their entire design system in a tailwind.config block (not in CSS); it's lifted into palette (every color token by name, e.g. primary→#a43b38), usage-ranked colors, and design_tokens = { spacing, radius, font_size, font_family } (the resolved spacing grid + corner radii + type scale, e.g. display-lg→48px, xl→80px) — LOCK the page's design system from these tokens and rebuild every element's size/spacing/color from them, mapping utility classes (text-primary, bg-surface-container-low, py-xl) back via the palette/tokens. (A <main> wrapper is flattened so its inner sections aren't collapsed; Material Symbols / icon webfonts are dropped from fonts.) For a screenshot/image input, no tool is needed — Claude analyzes it natively. Fonts + type scale: font_groups = the page's SELF-HOSTED fonts (lifted from @font-face rules in inline <style> AND — for ingest_url — the fetched external stylesheets incl. @imports), already in the settings.fontGroups shape: copy them into settings.fontGroups verbatim and set elements' styles.fontFamily to the group name; the font files are downloaded + uploaded to the org's media collection on save and the group is filed into the org's Font Groups (editor Font Manage) — the save result's fonts reports registered/merged/existing/failed (+ hotlinked when a file could not be re-hosted). Google-subset faces (unicode-range, fonts.gstatic.com) and icon fonts are skipped (Google families load by name). typography (h1…h6, p, body, a, button) and each section's text_styles (heading/body/cta) are the COMPUTED type — CSS cascade incl. @layer/@supports, @media desktop-vs-mobile, var(), fluid clamp()/calc() (evaluated at 1280/420 viewports), inline style, Tailwind text-*/font-* classes and inheritance: font_size (desktop px), mobile_font_size (when it differs), font_weight, font_family, line_height, letter_spacing — copy them exactly. Every save also adds Google Fonts links for weights 500/600/800/200 the build doesn't request (it loads only 100/300/400/700/900).
Bridge from Google Stitch (two-MCP flow). When a Stitch MCP is connected and the user references a Stitch design/screen: call the Stitch tool get_screen → it returns htmlCode.downloadUrl (a public Google contribution.usercontent.google.com/download?… URL serving the screen's HTML). Pass that URL straight to ingest_url(url, detail:'full') (or fetch it and use ingest_html). Stitch HTML is a Tailwind-CDN page, so you get the token-named palette + design_tokens — LOCK the design system from them and rebuild every element's size/spacing/color from the tokens. You also get gradients (Tailwind bg-gradient-to-* from-* to-* reconstructed as linear-gradient(...) with resolved stops — apply them as the CTA/hero background) and per-section hover_effects (scale/image-zoom/lift/underline/*-color-change) — REPRODUCE the hover via Webcake hover events (change_color with change_color_type, or animation_hover / the button's hovered* specials) so the clone isn't static. For anything beyond an element's built-in specials (hover scale/lift/zoom & transitions, gradients, glassmorphism/backdrop-blur, custom shadows, gradient text, custom keyframes), use the escape hatches: element specials.custom_css (declarations only) + specials.custom_class with specials.customAdvance:true, and page settings.extra_css (full raw stylesheet in <head> — where :hover/@keyframes/media-queries live, target #w-<element id>) + settings.extra_script (raw JS) + settings.bhet/settings.bbet (raw HTML blocks at end of <head>/<body> — webfont <link>s, <meta>/verification, analytics/pixels, chat widgets, third-party embeds; arbitrary HTML, not just CSS/JS). get_generation_guide has the recipes. SAFETY (custom is raw + global → sloppy custom breaks the whole UI): SCOPE every extra_css rule to #w-<id> or a specials.custom_class — never a bare tag / * / a Webcake-internal class (.section-container/.rectangle-css/.text-block-css/.group-*…); keep custom_css to VISUAL props only (no position/top/left/width/height/display/float); close every bhet/bbet tag; don't restructure #w-… elements in extra_script. validate_page flags unscoped selectors, layout props in custom_css, unbalanced braces/tags, missing customAdvance, and CSS/JS in the wrong field — fix every such warning. Carry the page's image URLs (Stitch hero/product/avatar images on lh3.googleusercontent.com/aida… and …/aida-public…, extension-less) into the matching slots verbatim: the save auto-hosts them (these googleusercontent hosts are recognised even without a file extension), so don't pre-call upload_images and don't placeholder them. Then create_page (dry_run:true → false). The Stitch project's named colors (project designTheme.namedColors / overridePrimaryColor) match the page palette, so what the user sees in Stitch is what the cloned page uses. Absolute-canvas exports (LadiPage-family pages / Webcake-published HTML — bare positioned <div id="HEADLINE123" class="ladi-element"> divs, layout in per-id CSS) are auto-detected and return an extra canvas payload: { builder, width (420 mobile / 960 desktop — SAME as the Webcake canvas), mobile_only, sections: [{ id, height, background, elements }], popups, element_count }, each element = { type, box (px top/left/width/height; fixed:true = floating/pinned), text, src (full-size original, CDN size prefix stripped), crop (inner image window when offset/zoomed — emulate via background-position/size if the target can't crop), style, animation ({name, duration, delay, iteration-count} from the builder's entrance/attention effects), input, events ({type:'popup'|'section'|'link'|'phone', action}), sticky, config (spin-wheel prizes decoded to [{label, chance}], countdown minutes, popup delay, autoplay…), children }. When canvas is present this is the HIGHEST-fidelity clone path: rebuild element-by-element using the boxes AS-IS (no rescaling — the canvas widths match), map types 1:1 (headline/paragraph→text-block, image→image, box→rectangle, button(+button_text)→button, form+form_item→form inputs, group→group, shape→svg shape, line→divider, countdown/carousel/spin_wheel→matching widgets, html_code→html-box), set options.mobileOnly from mobile_only, keep popups in the top-level popup array, wire events to the Webcake event vocab, and carry every src/background URL straight into the rebuilt elements — the save AUTO-HOSTS them to the Webcake CDN (the create/update/append/patch response carries a rehost summary), so you do NOT pre-call upload_images for canvas image URLs; just keep them un-altered (never placeholder a slot that had a real src). On big pages the full-page call may return canvas.truncated:true (styles pruned to core keys) — follow canvas.hint: re-call ingest_html/ingest_url with sections:[id] per section (ids from canvas.sections[].id; 'SECTION_POPUP' = the popups) to get FULL untrimmed detail, which pairs naturally with building incrementally via add_section. Garbled Vietnamese mojibake input is auto-repaired (warning attached). Role → element mapping hints: hero → section (background image/overlay) + text-block H1 + text-block subheading + button; features → group per card (icon rectangle + title text-block + body text-block); stats bar → group with text-block per stat; pricing → group with text list + button; footer → section (dark bg) + text-block + links.
Backend (need WEBCAKE_API_BASE + WEBCAKE_JWT env): list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page. create_page / update_page / add_section / patch_page / publish_page default to dry_run=true. create_page AUTO-PUBLISHES after a successful create (build host + publish_html) so the preview renders immediately — publish:false skips it; a publish failure never fails the create (result.publish carries the retry hint). The EDIT tools save source-only — after a round of edits run publish_page({ page_id, dry_run:false }) to refresh the rendered build, else the preview shows the stale pre-edit version. find_pages searches the account's pages by name, domain, and/or page id (AND-combined) to locate the page to edit when you don't already have a page_id — results include both custom_domain and default_domain to disambiguate by URL. add_section appends section(s) to an existing page server-side so you send only the new section, not the whole source — use it to build a LARGE page incrementally (create_page small skeleton → add_section per section) and avoid the giant single payload that can drop the connection. publish_page makes a page LIVE: calls the Webcake build host (POST <buildBase>/render/build, prod default https://build.webcake.io) to produce rendered app/app_css HTML, then publishes via the editor's publish_html route — the ONLY route that writes the PagePublishedV2 record all public serving reads. With custom_domain/custom_path the page is permanently live at that domain. Without a domain there is NO permanent public URL: the /preview/<page_id> link serves the stored rendered app for only ~10 minutes after the publish, then shows "Preview page is expired" — tell the user and suggest attaching a domain. An MCP-created page's preview is blank until a rendered publish (the AI save routes store source only). Without a build host publish_page falls back to a legacy source-only save (rendered:false, live:false) with a warning — nothing goes live. Set WEBCAKE_BUILD_BASE env or x-webcake-build-base header for staging/local (prod preset auto-configures). patch_page edits a page by element id without re-sending the whole source — send only per-element ops (update/replace/remove/add; update can set type to fix a wrong element type). Targets EITHER a live page (page_id) OR a cached draft (draft_id). It's the SMALL-EDIT path, the fix-after-error path, AND the retry-after-timeout path:
create_page → returns draft_id (full source cached ~2 h) → patch_page({ draft_id, patches, dry_run:false }) fixes bad elements and creates the page; or patch_page({ draft_id, dry_run:false }) with NO patches commits as-is (timeout retry).add_section dry_run=true, validation failure, or timeout → returns draft_id (section batch cached ~2 h) → patch_page({ draft_id, patches, dry_run:false }) fixes elements and appends; or patch_page({ draft_id, dry_run:false }) with no patches to commit as-is.update_page → returns draft_id (full source cached) → patch_page({ draft_id, dry_run:false }) with no patches retries the update.patch_page that timed out → response carries draft_id (patched source cached) → patch_page({ draft_id, dry_run:false }) with no patches retries.update_page validation failure → the page already has a page_id → patch_page({ page_id, patches }).COMMIT-AS-IS (universal timeout retry): patch_page({ draft_id, dry_run:false }) with empty/omitted patches — skips apply, re-validates, saves. Never regenerate the whole source to fix a few elements.
Reference docs in this repo: docs/page-element-schema.md, src/domains/landing/page-schema.json.
{ "page": [<section>...], "popup": [<popup>...], "settings": {...},
"options": { "currency":"VND", "mobileOnly":false, "versionID":null }, "cartConfigs": {} }page = sections stacked vertically; popup is a SEPARATE top-level array (NOT inside page).{ id, type, properties:{name,movable,sync}, responsive:{desktop,mobile:{config,styles}}, specials, children, runtime, events }.element from its type's factory defaults, so emit ONLY id, type, responsive.desktop.styles + responsive.mobile.styles (BOTH breakpoints), specials, and events when the element really has them. OMIT properties/runtime/empty events+children/per-breakpoint config. The whole loop is sparse: get_element skeletons/examples and new_element come in this shape (copy as-is), get_page returns the source COMPACTED the same way (edit + send back without re-adding boilerplate), and create_page/update_page/add_section/patch_page/validate_page all hydrate before validating/persisting. A full node still works.
top/left/width/height (px) per breakpoint; sections own a height, no top/left. The canvas WIDTH is a per-breakpoint CHOICE in settings.width_section — desktop 960 or 1200, mobile 420 or 360 (editor-allowed values only; default 960/420). Pick 1200 desktop for wide/multi-column/editorial layouts or when cloning a reference wider than 960 (e.g. Google Stitch ~1280, so columns aren't squished into 960), else 960; 360 mobile to match a ~360–390 design. Place every element's coords in the chosen width's space (changing width does not rescale them); new_page_skeleton(desktopWidth, mobileWidth) sets it up front.specials (text, src, field_name…), NEVER in styles. Colors as rgba(...) on BOTH breakpoints — the editor'scolor traits only parse rgba() and show black for hex/rgb()/hsl()/named colors, and read one breakpoint at a time with no desktop→mobile fallback. The server auto-converts and mirrors color/backgroundTxt/borderColor, but write it correctly anyway.
config.animation = {name,delay,duration,repeat}. Event: {id,type,action,target,appTarget,hoverColor}.list_organizations first; exactly 1 org → it auto-selects; 2+ orgs → ask the user and hold onto the chosen organization_id, because the SAME org must go to BOTH create_page AND upload_images (upload_images fails with reason:"organization_required" on an unsettled org, so leaving it for later stalls the image step mid-build). Pass organization_id:"personal" only when the user explicitly wants no org. Then RESTATE a short outline (sections + CTA + colors) and wait for the user's confirmation before generating. Don't generate + persist on the same turn as the request.get_generation_guide, then new_page_skeleton.get_element per type (specials + sparse example); new_element for sparse skeletons.{ page, popup, settings, options, cartConfigs } from SPARSE nodes; fill specials, set coordinates (no overlaps). For intricate composite visuals (phone/chat mockup, mini dashboard, browser frame, ticket card) use ONE html-box with fully inline-styled HTML (root div width:100%;height:100%;overflow:hidden; flex/grid allowed inside; content must fit styles.height) instead of dozens of absolute-positioned elements — never for primary copy, CTAs, forms, or event targets.validate_page → fix every error AND every warning (warnings are visible defects — text spilling onto the element below, off-canvas boxes, empty bands; re-validate until the list is empty, only a demonstrably false positive may remain).list_organizations → if exactly ONE org exists, create_page auto-selects it (no need to ask). If MULTIPLE orgs exist, show them and ask the user which to use (highlight is_default as the suggested default); pass the chosen organization_id to create_page. Pass organization_id:"personal" only when the user explicitly wants no org. create_page enforces this: 2+ orgs with no organization_id → it returns the org list and asks you to pick.create_page dry_run:true (preview) → dry_run:false with chosen organization_id (or omit if auto-selection applies).create_page auto-published the page, so preview_url renders right away — but only for ~10 minutes after each publish (then "Preview page is expired"). After subsequent EDITS, re-run publish_page({ page_id, dry_run:false }) to refresh the rendered build. Only publish_page({ page_id, custom_domain, custom_path?, dry_run:false }) gives a permanent public URL — without a domain, tell the user the preview link is temporary and suggest attaching one.find_pages({ name?, domain?, page_id? }) to locate the page by name/domain/id (or list_pages to browse; or take a page_id straight from a URL).get_page(page_id) → the live { page, popup, settings, ... }, COMPACTED to the sparse shape (pass compact:false only if you need the raw stored tree).id, and coordinates; send the compacted tree back as-is (no boilerplate). To add: new_element, unique id, place in the right section's children.validate_page → update_page(page_id, source) (dry_run:true then dry_run:false).Concurrent editing. The user may be in the Webcake editor on the same page. update_page replaces the whole stored tree, so a source read earlier — or a draft_id cached up to ~2 h ago — would revert their work. get_page therefore returns source_version, and update_page re-reads the page before saving: a live tree that no longer matches the baseline is refused with reason:"page_changed_externally" plus the element ids the save would have deleted (with no baseline at all it still refuses any save that drops live ids: reason:"unverified_overwrite"). Recover by re-reading and re-applying, or better by using patch_page({ page_id, patches }) for edits — it merges into the tree it just read, so it can never revert anyone. add_section is safe for the same reason. Pass force:true only after the user says to discard the newer changes.
validate_page before any create/update; fix every error AND every warning — warnings are a fix list, not advisory noise (tool responses repeat this as warnings_notice). Never report the page done while warnings stand.dry_run first; send dry_run:false only after user confirms.create_page and upload_images. Resolution: 1 org → auto-selected; 2+ orgs → ask user, pass organization_id (both tools refuse to guess — upload_images returns reason:"organization_required"); "personal" = explicit no-org. Set WEBCAKE_ORG_ID to skip the lookup.specials.field_name (canonical keys for auto-typing).top/left/width/height/fontSize; colors rgba(); only containers have children.properties/runtime/empty events+children/config — the server hydrates them); when you DO send runtime, it is {}.npm install && npm run build, then register the MCP with env WEBCAKE_API_BASE, WEBCAKE_JWT (+ optional WEBCAKE_ENV, WEBCAKE_ORG_ID, WEBCAKE_APP_BASE). The backend AI endpoints (/api/v1/ai/*) must be running (landing_page_backend, branch feat/ai-page-element-mcp).
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.