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. 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 (see below). The case that still needs upload_images is a user's LOCAL FILE PATH (the save can't read local files). 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 }. 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.
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.
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(...).config.animation = {name,delay,duration,repeat}. Event: {id,type,action,target,appTarget,hoverColor}.list_organizations to check first; if exactly 1 org, save into it automatically; 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).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.organization_id; "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.