spideriq_content_reference:
  version: 11.14.0
  description: Build and deploy websites using Liquid templates + SpiderIQ CMS content.
  merge_tags_reference: 'For dynamic-landing page authoring: GET /api/v1/content/variables.
    Returns ~40 flat email-marketing-style merge tags ({{firstname}}, {{company_name}},
    {{city}}, {{industry}}, {{email}}, ...) with descriptions, example values, and
    selection rules. MCP tool: content_get_variables.'
getting_started:
  summary: You are an AI agent building a website on SpiderIQ. Before touching any
    tools, bind a session, then consult the `tasks:` map below for your specific goal.
  steps:
  - 1. `spideriq use <project_id>` — binds this cwd to a project. Writes spideriq.json.
    Without this, every dashboard call 403s.
  - '2. For dynamic-landing pages: GET /content/variables — reads the merge-tag vocabulary
    ({{firstname}}, {{company_name}}, {{city}}, …). Mailchimp-style flat tags, discoverable
    in one call. See §merge_tags.'
  - '3. Workflow is always: create/update content → publish → deploy.'
  - '4. Destructive ops (publish, deploy, delete, update_settings, apply_theme, delete/publish/archive
    component) are 2-phase: first call with ?dry_run=true returns a confirm_token,
    then call again with ?confirm_token=<token> to execute.'
  - '5. To find the right tools for your goal: scan `tasks:` below. Each task names
    the exact tool sequence and links to detail.'
  common_mistakes:
  - Setting `primary_color` expecting it to change the page background — it's the
    ACCENT color. Background uses `surface_color` (see §theme_palette).
  - Creating a custom component with slug 'footer' to override the default — components
    and theme sections are different subsystems. Use content_override_section or upload
    via `template_upsert` with path='sections/footer.liquid' instead.
  - Building JS Shadow-DOM-escape hacks to modify page chrome — you never have to.
    See §chrome_override for the supported path.
  - Forgetting to publish components before deploy — draft components don't render
    on the live site.
  - Creating a page with slug '/' — slug 'home' is the implicit homepage.
  - Trying to PATCH a `dm-blog-listing` (or any branded blog component) — no such
    component exists. The blog UI is template-based, not block-based. Restyle it with
    content_override_section(section_slug='blog-listing'|'blog-post', ...) or by creating
    a CMS page at slug 'blog'. See §customize_blog.
tasks:
  build_a_landing_page:
    when: first-time authoring of a marketing or landing page
    steps:
    - content_create_page(slug, template='landing', blocks=[...])
    - content_publish_page(id)
    - content_deploy_preview() → returns confirm_token
    - content_deploy_production(confirm_token)
    see:
    - pages
    - page_templates
    - deploy_workflow
  build_a_personalized_landing_page:
    when: per-visitor landing page that shows each lead their own name, city, industry,
      etc. (dynamic landing from CRM data)
    preferred_path: Create a page with template='dynamic_landing' and sprinkle merge
      tags into its blocks. At render time, the URL identifier (place_id / domain
      / email / google_place_id) resolves to an IDAP business row, and the template
      gets populated with 40+ flat merge tags + 6 loop arrays. All tags are null-safe
      — missing data renders '' instead of crashing.
    steps:
    - 'content_get_variables(format=''yaml'')  # fetch the full vocabulary (~40 tags
      + 6 arrays)'
    - content_create_page(slug, template='dynamic_landing', blocks=[{type:'hero',
      data:{heading:'Hey {{firstname}} at {{company_name}}...'}}])
    - content_publish_page(id)
    - '# NO deploy required — dynamic landing renders live via the content API'
    - '# Preview with canned data: /lp/{slug}/demo → Mario''s Pizzeria fixture (fully
      populated)'
    - '# Live URL: /lp/{slug}/{place_id} OR /lp/{slug}/{salesperson_slug}/{place_id}'
    common_merge_tags:
      company: '{{company_name}}, {{legal_name}}, {{industry}}, {{description}}, {{website}},
        {{domain}}, {{logo}}, {{photo}}, {{rating}}, {{reviews_count}}, {{lead_score}}'
      contact: '{{firstname}}, {{lastname}}, {{full_name}}, {{job_title}}, {{email}},
        {{phone}}, {{mobile}}, {{linkedin_url}}'
      location: '{{address}}, {{city}}, {{region}}, {{state}}, {{country}}, {{country_code}},
        {{postal_code}}, {{zip}}'
      vitals: '{{team_size}}, {{founded}}, {{revenue}}'
      salesperson: '{{salesperson.name}}, {{salesperson.title}}, {{salesperson.bio}},
        {{salesperson.calendar_url}} (only when URL includes salesperson slug)'
    loop_arrays:
      emails: '{% for e in emails %}{{ e.address }} ({{ e.status }}){% endfor %}'
      phones: '{% for p in phones %}{{ p.number }} ({{ p.type }}){% endfor %}'
      contacts: '{% for c in contacts %}{{ c.full_name }} - {{ c.position }}{% endfor
        %}'
      officers: '{% for o in officers %}{{ o.name }} ({{ o.role }}){% endfor %}'
      categories: '{% for cat in categories %}{{ cat }}{% endfor %}'
      pain_points: '{% for pp in pain_points %}- {{ pp }}{% endfor %}'
    escape_hatch:
      raw_lead: '{{ lead.related.domains[0].company_vitals.tech_stack }} — for fields
        not surfaced as flat tags'
      when_needed: most workflows should stick to flat tags; reach into lead.* only
        when you have a specific nested field in mind
    ecosystem_data_sources:
      SpiderMaps: '{{company_name}}, {{rating}}, {{reviews_count}}, {{categories}},
        {{phone}}, {{address}}, {{city}}, {{country_code}}, {{photo}}'
      SpiderSite: '{{industry}}, {{team_size}}, {{founded}}, {{pain_points}}, {{logo}},
        {{lead_score}}'
      SpiderVerify: '{{emails}} status/score/deliverable'
      SpiderCompanyData: '{{legal_name}}, {{vat_number}}, {{registration_number}},
        {{revenue}}, {{officers}}'
      SpiderPeople: '{{firstname}}, {{lastname}}, {{job_title}}, {{linkedin_url}},
        {{contacts}}'
    merge_tag_example: <h1>Hey {{ firstname }} at {{ company_name }}, we saw your
      {{ rating }}★ in {{ city }}</h1>
    anti_patterns:
    - 'DO NOT assume a tag will be populated — all singulars default to '''' (empty
      string). Use Liquid default filter: {{ firstname | default: ''there'' }}'
    - DO NOT confuse {{firstname}} (the lead's top contact) with {{salesperson.name}}
      (the rep assigned in the URL)
    - DO NOT use the raw `lead.*` nested shape when a flat tag exists — {{company_name}}
      beats {{lead.name}}
    - DO NOT forget to set template='dynamic_landing' at page create time — the renderer
      dispatches on this field
    - DO NOT expect /lp/{slug}/{id} to work without a published page at that slug
      — regular pages at slug 'foo' don't accept /lp/foo/... URLs
    - DO NOT dereference nested lead.* in a CUSTOM dynamic_landing template without
      an `{% if lead %}` guard — when the id doesn't resolve, `lead` is null. It won't
      500 (strictVariables is off), but unguarded blocks render blank. See dynamic_landing_pages.null_lead_contract.
    preview_workflow:
      demo_fixture: /lp/{slug}/demo — Mario's Pizzeria (Miami Beach). Every tag populated
        for authoring preview.
      real_lead: /lp/{slug}/{place_id} — resolves the IDAP business row by place_id,
        domain, or email
      with_salesperson: /lp/{slug}/{salesperson_slug}/{place_id} — adds {{salesperson.*}}
        tags from site config
    see:
    - merge_tags_reference
    - dynamic_landing_pages
    - page_templates
  publish_a_changelog_entry:
    when: ship release notes / product updates — there IS a built-in changelog content
      type, route (/changelog), RSS+Atom feeds, and dashboard UI. You do NOT need
      to build it.
    tool_naming_note: 'The changelog tools break the content_* prefix every other
      content type uses. They are named changelog_create / changelog_list / changelog_update
      / changelog_publish / changelog_delete — NOT content_create_changelog_entry.
      Searching for ''content_*_changelog'' returns nothing; search ''changelog''
      instead. Entries are NOT frozen once created: changelog_update edits title/version/body
      of an existing (even published) entry, changelog_delete soft-archives it.'
    steps:
    - 'changelog_list()  # discover existing entries + their ids (a brand new tenant
      has 0 — that''s empty, not missing). Entry BODIES are omitted by default; pass
      include_body=true (with limit) only when you need the prose'
    - 'changelog_create(version=''1.4.0'', title=''...'', body={...tiptap...}, tags=[''new'',''fix''])  #
      creates a DRAFT'
    - 'changelog_publish(id)  # goes live at /changelog + /changelog/feed.xml + /changelog/atom.xml;
      notifies subscribers'
    - 'changelog_update(changelog_id=id, title=''...'', version=''1.4.1'', body={...})  #
      PATCH — correct an existing entry; only sent fields change'
    - 'changelog_delete(changelog_id=id)  # soft-archive — drops off /changelog +
      feeds'
    body_format_note: 'body is a Tiptap JSON document, but you may ALSO send {''markdown'':
      ''...''} or {''html'': ''...''} — it is normalized to Tiptap server-side (a
      markdown body no longer stores clean then renders title-only).'
    no_deploy_required: Published entries render live from the API — no content_deploy_site
      needed for the entry to appear.
    default_template: The default theme ships templates/changelog.liquid. template_get('templates/changelog.liquid')
      404s ONLY because that reads YOUR tenant KV overrides — the default-theme fallback
      still renders /changelog.
    customize_appearance: TWO ways to restyle /changelog, with different scope. (1)
      template_upsert('templates/changelog.liquid', ...) — restyles the built-in timeline,
      but templates are PER-CLIENT (shared by every project/domain in this workspace)
      and an override MUST wrap its markup in `{% block content %}` … `{% endblock
      %}` (under `{% layout %}`, LiquidJS discards anything outside a named block
      → a bare body renders empty chrome, not your markup). (2) Create + publish a
      CMS PAGE at slug 'changelog' — pages are PER-PROJECT (served by the requesting
      domain), so a two-project workspace can ship a fully custom, per-domain changelog
      on a secondary site without touching the shared template. The built-in /changelog
      route renders that page instead when present.
    see:
    - changelog
    - live_collections
  customize_docs_styling:
    when: restyle /docs/* — colors, accent, fonts, layout — to match your brand. You
      do NOT have to rewrite the whole doc.liquid template.
    approach_colors_first:
      tool: content_update_settings
      fields:
      - primary_color
      - surface_color
      - surface_elevated_color
      - subtle_color
      - body_text_color
      - heading_color
      note: These inject as CSS custom properties (--primary, --surface, --body-text,
        --heading …) into every page's <head>, INCLUDING /docs/* pages. The default
        doc.liquid uses them for link color, borders, and text — so changing settings
        reskins docs with no template edit.
    approach_extra_css:
      tool: content_update_settings
      field: custom_head_scripts
      note: Drop a <style> block here to override anything else on docs pages. Requires
        deploy.
    approach_full_layout:
      when: you need to change docs structure (sidebar width, breadcrumbs), not just
        colors
      tool: content_override_section / template_upsert(path='templates/doc.liquid')
      note: Last resort. The default ships clean + themeable; reach for a full template
        rewrite only for structural changes.
    misconception: If template_get('templates/doc.liquid') returns a big inline-CSS
      template, that's YOUR tenant's prior customization in KV — not what SpiderPublish
      ships. The default doc theme is small and var-driven with no !important.
    see:
    - theme_palette
    - settings
    - chrome_override
  author_a_custom_template:
    when: you template_upsert a FULL page template (changelog/doc/blog/landing/etc.)
      instead of editing the default. Skip this if you only change colors/copy — prefer
      customize_docs_styling / theme_the_site / override_header_or_footer.
    warning: A custom full template REPLACES the default in your tenant KV. If it
      omits the contract below you lose site chrome, theme tokens, head assets, and
      rich-text rendering — and the page can 500. The shipped defaults already do
      all of this; deleting your override restores them.
    contract:
      1_layout: FIRST line MUST be {% layout 'layout/theme.liquid' %} (the FULL path
        — bare 'theme' 404s → Template-error page). The layout is what injects <head>,
        theme tokens (:root --primary/--surface/…), and the header/footer chrome.
        No layout tag ⇒ a bare fragment with none of those.
      2_components: 'Render header/footer/marketplace components with the Liquid tag
        {% component slug: "modern-header" %} (note the colon). A hand-written raw
        <spideriq-cmp data-slug="…"> tag is passed through verbatim and NEVER server-rendered
        — only the {% component %} tag emits the <template shadowrootmode> markup.'
      3_rich_text: Render Tiptap bodies with {{ entry.body | tiptap_html }} (filter).
        The field is `body` (Tiptap JSON); there is NO body_html / body_rendered /
        body_text — those return '' (silently).
      4_content_block: 'Wrap your markup in {% block content %}…{% endblock %} so
        the layout slots it in. With {% layout %} set, LiquidJS DISCARDS anything
        OUTSIDE a {% block %} — out-of-block markup renders NOTHING (you get bare
        chrome / a blank body, no error). This is the #1 silent failure: an override
        that forgets the block wrapper looks like ''the home page''.'
    context_variables_by_template:
      templates/changelog.liquid: 'changelog_entries: [{version, title, body (tiptap),
        published_at}], changelog_total: int'
      templates/blog.liquid: 'posts: [{title, slug, excerpt, body, published_at, author,
        tags}], pagination'
      templates/blog-post.liquid: 'post: {…}, related_posts'
      templates/docs.liquid + doc.liquid: 'doc: {title, body, full_path}, docs_tree'
      templates/dynamic-landing.liquid: lead (NULLABLE — guard {% if lead %}), salesperson,
        page.custom_fields
      templates/index.liquid + page.liquid: 'page: {title, blocks, custom_fields},
        plus global posts/authors/categories/tags/changelog'
    lint_on_save: template_upsert returns a non-blocking `template_warnings` array
      when a full-page template drops {% layout %}, uses a bare layout path, puts
      markup OUTSIDE any {% block %} (the silent-discard footgun → rule `markup_outside_block`),
      contains a raw <spideriq-cmp> tag, or references body_html — read it after every
      upsert; an empty/absent array means the template follows the contract.
    verify: 'After upsert, fetch the page HTML: a server-rendered component shows
      <template shadowrootmode="open">; theme tokens show a :root{ --primary… } block;
      a ''Template error'' page (or an HTML comment ''SpiderIQ render error: Template
      not found…'') means a bad {% layout %}/{% render %} path.'
    preview_before_deploy: POST /dashboard/templates/preview now RENDERS server-side
      (the real engine) and returns { html, warnings[] } — no deploy needed to see
      true output. mode='template' renders one template with `data`; pass `content`
      to preview an UNSAVED edit. mode='page' + `path` (e.g. '/changelog') renders
      the whole route with your live content + theme + chrome, scoped to the active
      project's domain. warnings[] carries the same rules as lint_on_save (markup_outside_block,
      bad_layout_path, render_error, …) but confirmed against a real render. Use it
      to catch an off-center heading or a discarded {% block %} BEFORE content_deploy_site.
    see:
    - customize_docs_styling
    - publish_a_changelog_entry
    - dynamic_landing_pages
    - chrome_override
  theme_the_site:
    when: change surface/background/text colors or brand accent
    approach_simple:
      tool: content_update_settings
      fields:
      - primary_color
      - surface_color
      - surface_elevated_color
      - subtle_color
      - body_text_color
      - heading_color
      example_light_site: surface_color='#ffffff', surface_elevated_color='#f5f5f5',
        subtle_color='#e5e5e5', body_text_color='#18181b', heading_color='#0a0a0a'
      example_dark_site: 'all nulls — the default palette IS dark (#0A0A0B surface,
        #ffffff heading)'
    approach_advanced:
      when: you need to change layout, not just colors
      tools:
      - content_override_section
      - content_apply_layout_preset
      see: chrome_override
    see:
    - settings
    - theme_palette
  override_header_or_footer:
    when: default chrome doesn't match brand AND color-only changes aren't enough
    steps:
    - content_get_section_source(section='footer') → current Liquid source
    - modify the Liquid in your own context (swap classes, add markup)
    - content_override_section(section='footer', liquid=modified)
    - content_deploy_preview() → content_deploy_production(confirm_token)
    note: This is the CANONICAL escape hatch for chrome customization. Used by danmagi,
      sms-chemicals, and SpiderMail today. Do NOT build JS Shadow-DOM-escape hacks.
    see:
    - chrome_override
  remove_header_or_footer:
    when: you want a full-bleed hero with no site chrome
    options:
    - 'Option A — PER-PAGE: set page.template=''blank'' when creating the page. That
      page renders without header/footer/body classes. Other pages unchanged.'
    - 'Option B — SITE-WIDE: content_apply_layout_preset(preset=''blank''). Uploads
      a layout/theme.liquid override that strips chrome from every page.'
    see:
    - page_templates
    - chrome_override
  build_a_login_page:
    summary: Add a sign-in page using the designable Authentication components (login
      / forgot-password / reset-password).
    when: the client wants a login, forgot-password, or reset-password page — either
      to sign into the SpiderIQ dashboard (auth_target=dashboard) or into the client's
      own site members (auth_target=site_members).
    category: authentication
    components:
    - spideriq/auth-login
    - spideriq/auth-forgot-password
    - spideriq/auth-reset-password
    required_prop:
      auth_target:
        values:
        - dashboard
        - site_members
        description: REQUIRED on every auth component. Picks the identity system.
          Ask the client which one they want before inserting — the two are different
          worlds and cannot be swapped after sign-in.
    steps:
    - '1. Discover the components: GET /content/marketplace/components?category=authentication
      (returns the 3 rows + their props_schema).'
    - '2. Decide auth_target with the client: ''dashboard'' = sign into the SpiderIQ
      dashboard; ''site_members'' = sign into the client''s own members area (Initiative
      C).'
    - '3. Create a page (content_create_page) for /login, then add spideriq/auth-login
      to it with props at least {"auth_target": "dashboard"} (or site_members). Add
      api_base/theme/methods/signup_enabled/forgot_link as needed.'
    - 4. (Optional) Add /forgot-password (spideriq/auth-forgot-password) and /reset-password
      (spideriq/auth-reset-password) pages; wire forgot_link/login_link between them
      as page refs.
    - 5. Publish + deploy, then content_visual_check the published URL and assert
      dom.shadow_hosts includes 'spideriq-auth'.
    example_props:
      auth_target: dashboard
      api_base: https://spideriq.ai
      methods:
      - email_password
      - google
      signup_enabled: true
      forgot_link: /forgot-password
      redirect_after: /dashboard
      theme:
        primary_color: '#6d28d9'
        button_radius: 8px
    common_mistakes:
    - Omitting auth_target — it is REQUIRED; the component cannot guess which identity
      system to use.
    - Forgetting the page chrome is yours — sign-in is LIVE end to end (both auth_target
      values work). Design the whole page however you like and embed the brick where
      the form goes; only the form interior is themed via theme tokens.
    - Assuming the password field is readable from the page — it is NOT; <spideriq-auth>
      uses a CLOSED shadow DOM by design. Assert on dom.shadow_hosts, never on body_text_preview
      (catalog Rule 62).
    - Initiating OAuth on the tenant domain — for auth_target=dashboard, google/github
      ALWAYS redirect to the central origin for the OAuth dance.
    see:
    - marketplace.authentication
    - components
    - build_a_page_with_blocks
  customize_blog:
    when: restyle /blog (the listing) or /blog/{slug} (single posts) — change the
      layout, drop in a custom hero, swap the post-card markup, etc.
    important: There is no `dm-blog-listing`, `blog-listing`, or any other component
      for the blog. The blog UI is TEMPLATE-based, not block-based. Do NOT try to
      PATCH a content_components row for the blog — that row does not exist and the
      PATCH will fail. The actual files are templates/blog.liquid (the listing) and
      templates/blog-post.liquid (single posts) in the bundled default theme. Override
      them per-tenant with the tools below.
    approach_simple:
      tools:
      - content_override_section(section_slug='blog-listing', liquid_source=<custom
        blog.liquid>)
      - content_override_section(section_slug='blog-post', liquid_source=<custom blog-post.liquid>)
      note: These are thin wrappers — they write to templates/blog.liquid and templates/blog-post.liquid
        in the per-tenant override KV. The Liquid engine merges per-client KV over
        the bundled default theme automatically.
    approach_block_composition:
      when: you want /blog to compose other blocks (custom hero / footer / FAQ block)
        instead of a fixed Liquid template
      steps:
      - content_create_page(slug='blog', template='default', blocks=[...])
      - content_publish_page(id)
      - content_deploy_preview() → content_deploy_production(confirm_token)
      - '# /blog now renders the page''s blocks instead of the hardcoded listing.'
      - '# /blog/tag/{tag} keeps the legacy listing — there is no per-tag CMS-page
        hook.'
      note: Fully opt-in. Tenants without a CMS page at slug 'blog' see the legacy
        hardcoded listing. To switch back, delete the page.
    approach_underlying_api:
      when: you'd rather call the template API directly
      tools:
      - 'template_get(path=''templates/blog.liquid'')  # bundled default if no override
        exists'
      - template_upsert(path='templates/blog.liquid', content=<modified>)
      - template_upsert(path='templates/blog-post.liquid', content=<modified>)
      - 'template_upsert(path=''snippets/post-card.liquid'', content=<modified>)  #
        card reused by listing + related posts'
      cli_equivalent: spideriq templates set 'templates/blog.liquid' --file ./blog.liquid
    do_not:
    - Build a custom component named `dm-blog-listing`, `<brand>-blog-v2`, etc. and
      try to PATCH it — there is no such component in SpiderIQ.
    - Create a page at slug 'our-blog' with a hardcoded posts grid as a workaround.
      Use approach_block_composition above to keep the canonical /blog URL with native
      pagination.
    - Forget that pagination is built into templates/blog.liquid — if you replace
      it with a block-composition page, you lose server-side pagination and have to
      fetch /api/v1/content/posts?page=N yourself.
    see:
    - chrome_override
    - blog_overrides
    - pages
  add_scroll_linked_hero:
    when: building a cinematic scroll-sequence hero like danmagi.com
    preferred_path: 'ONE tool call: video_to_scroll_sequence(video_url, page_slug).
      It submits extract_frames, polls to completion, and inserts a sys-scroll-sequence
      block into the target page AS A DRAFT. Never auto-publishes — caller still runs
      deploy_preview → deploy_production with confirm_token. Available in @spideriq/mcp-publish
      v2.87.0+.'
    one_shot:
      tool: video_to_scroll_sequence
      example: video_to_scroll_sequence(video_url='https://media.cdn.spideriq.ai/.../hero.mp4',
        page_slug='home', target_frames=120, scroll_distance_vh=400)
      returns: '{job_id, manifest:{base_url,pattern,count}, block, page:{slug,status,blocks_count,block_index}}'
      next_steps:
      - content_deploy_site_preview → open preview_url, scroll through, verify no
        black frames
      - content_deploy_site_production(confirm_token=<from deploy_preview>)
      common_variants:
      - target_frames=180, scroll_distance_vh=600  — longer cinematic hero
      - strategy='fps', fps=24                      — FPS-based sampling
      - 'position={''before'': ''hero-gradient''}        — insert before existing
        block'
      - dry_run=true                                 — get block JSON without touching
        page
    legacy_5_step_recipe:
      when_to_use: MCP server older than v2.87.0, or you need to split the steps for
        custom flows
      steps:
      - 1. Reference or upload source video — public URL required (SpiderMedia / di-atomic
        preferred)
      - 2. submit_job(type='spiderVideo', payload={action:'extract_frames', video_url,
        strategy:'target_frames', target_frames:120, output_format:'webp'})
      - 3. Poll get_job_results until status='completed' — manifest at data.{base_url,
        pattern, count}
      - 4. content_update_page — append block of shape {type:'component', component_slug:'sys-scroll-sequence',
        props:{base_url, pattern, count, scroll_distance_vh:400, preload_strategy:'progressive'}}
      - 5. content_deploy_site_preview → verify → content_deploy_site_production(confirm_token)
    anti_patterns:
    - DO NOT hardcode 100+ frame URLs in component JS — bundle bloat + concurrent
      GET flood causes CDN rate-limit drops → black frame strobe
    - DO NOT tunnel local frames via pinggy/serveo/localhost.run into /media/files/import-url
      — free tunnels inject HTML interstitials that import-url saves as .webp → silent
      black frames
    - DO NOT build your own scroll-sequence component when sys-scroll-sequence already
      handles it
    - DO NOT call content_deploy_site_production without reviewing the preview URL
      first
    - DO NOT paginate content_list_components looking for 'sys-scroll-sequence' —
      call content_get_component_by_slug('sys-scroll-sequence') directly
    recipe_skill: github.com/SpiderIQ/SpiderPublish/tree/main/designer-kit/skills/recipes/scroll-sequence
    see:
    - components
    - tier3_cdn_allowlist
    - agent_skills
  rollback_component:
    when: a component update broke something and you want to revert — either the last
      update_and_propagate was bad, or a recent create_component / update_component
      needs undoing
    preferred_tool: component_rollback (v2.88.0+)
    one_shot: component_rollback(slug, target_version='1.0.3', dry_run=true) → preview
      → re-run with confirm_token to apply. Creates a NEW published version (e.g.
      1.0.4) with the content from target_version, and repoints every consuming page
      to it.
    why_new_version_not_reset: Rollback creates a forward-only version (never deletes
      or modifies history). You can always see 'v1.0.4 was a rollback of v1.0.0' in
      the audit trail. Pages now pin v1.0.4 — their block JSONB was updated in place.
    staged_rollback: 'Pass `pages: [''home'']` to repoint only specific pages. Other
      pages keep their current pin (whether that''s the broken version or an older
      one). Useful for canary rollback — fix one page, verify, then roll to all.'
    find_target_version: Call `content_list_component_versions(slug)` first to see
      what versions exist. Pick a known-good version from the history.
    gate_action: Gate action is `component_rollback` (distinct from `component_update_and_propagate`).
      A token issued for one CANNOT be consumed by the other — Lock 4 prevents that
      cross-use.
    see:
    - update_component_site_wide
    - iterate_on_a_component
    - deploy_workflow
  update_component_site_wide:
    when: you want to change a component's HTML/CSS/props/etc. AND have the change
      apply to every page using it (or to a subset) — most CTA/header/footer/shared-block
      edits fall here
    preferred_tool: component_update_and_propagate (v2.88.0+)
    one_shot: component_update_and_propagate(slug, html_template=..., css=..., bump='patch',
      dry_run=true) → inspect affected_pages in the preview → call again with confirm_token=<from
      dry_run> to apply. One call bumps the component AND repoints every consuming
      page's block to the new version, inside a single transaction.
    staged_rollout: 'Pass `pages: [''home'']` to limit the version pin update to those
      specific pages. Other consumers keep their old version pin. Useful for canary
      rollouts — preview on one page, validate, then call again with `pages` omitted
      to roll to all.'
    why_not_component_update: '`component_update` only touches the component row —
      pages that pin the old `component_version` keep rendering the old version. To
      roll a change site-wide you''d need to manually list affected pages, PATCH each
      one''s blocks, and coordinate confirm_tokens per page. The one-shot collapses
      that 5-step choreography into one atomic call with one confirm_token.'
    deploy_semantics: Block-level page content renders live via the content API on
      next request — NO tenant deploy needed for this flow. Only run content_deploy_site_preview
      + content_deploy_site_production if you ALSO changed templates/theme/config
      (which live in KV and require a Worker bounce).
    version_bump: 'Default `bump: patch` (1.4.2 → 1.4.3). Use `minor` for backward-compat-breaking
      prop changes, `major` for contract breaks. The service auto-computes the next
      version and errors if that version already exists — bump one step higher if
      so.'
    anti_patterns:
    - DO NOT chain component_update + many content_update_page calls + many content_publish_page
      calls — use this one-shot instead
    - DO NOT paginate content_list_components to find the slug — call content_get_component_by_slug(slug)
      first
    - DO NOT include <style> tags in html_template — the Liquid renderer injects CSS
      via the separate css field; inline <style> is silently ignored at render time
      (validator now rejects it with a 400)
    see:
    - iterate_on_a_component
    - components
    - deploy_workflow
  iterate_on_a_component:
    when: you're editing a component and want to verify it renders before touching
      a page or deploying
    find_component_first: If you know the component's slug (`hero`, `pricing-cards`,
      etc.), call `content_get_component_by_slug(slug)` FIRST — one GET, returns the
      full record. Only fall back to `content_list_components` with filters if you
      DON'T know the slug. Do NOT paginate `content_list_components` looking for a
      specific slug — that's an N-call loop where one call suffices.
    if_updating_many_pages: When the component is used on multiple pages, PREFER `component_update_and_propagate`
      (see `update_component_site_wide` task) over the iterate-then-update flow. One
      call handles component bump + page block repoint + single confirm_token.
    steps:
    - 1. content_get_component_by_slug(slug) → grab the current html_template + css
      + props_schema + version
    - 2. template_preview with the edited HTML + CSS + JS + props — returns the REAL
      rendered `html` + a `warnings[]` array (pure, no DB writes; runs the production
      renderer engine server-side)
    - 3. Read `warnings[]` (markup_outside_block, bad_layout_path, render_error, …)
      and eyeball the `html`
    - '4. If broken: edit source, call template_preview again'
    - '5. Once correct: content_update_component (dry_run=true → confirm_token → second
      call)'
    - 6. content_publish_component (dry_run=true → confirm_token)
    - 7. If the component is on multiple pages and you want the update everywhere,
      use `component_update_and_propagate` (v2.88.0+) instead of steps 5-6 — one call
      updates the component AND every consuming page
    - 8. content_deploy_site_preview → verify → content_deploy_site_production(confirm_token)
    note: template_preview is pure — no DB writes, no deploy. It now RENDERS server-side
      (real engine, real theme) and returns `html` + `warnings[]` — not raw source.
      Pass `content` to preview an UNSAVED template body; pass `mode='page'` + `path`
      (e.g. '/changelog') to render a whole route with live content scoped to the
      active project's domain. Use it freely during the edit-debug loop. Save-and-publish
      are gated by confirm_token so you can't accidentally ship a broken version.
    recipe_skill: github.com/SpiderIQ/SpiderPublish/tree/main/designer-kit/skills/recipes/preview-iteration
    see:
    - components
    - deploy_workflow
  upload_many_local_files:
    when: you have a local file or directory (screenshots, scroll-sequence frames,
      logos, PDFs) that needs to live on the CDN
    preferred_path: ONE MCP/CLI call. Use upload_local_file for a single file or upload_local_directory
      for a whole directory. Scroll-sequence folders auto-optimize (Sharp → WebP q75,
      max 1920px wide) before upload and auto-enable preserve_filename so CDN keys
      match the {base_url, pattern, count} shape. Requires @spideriq/mcp-publish@0.1.5+
      or CLI 0.9.4+.
    one_shot_examples:
    - upload_local_file(local_path='./logo.webp', folder='brand')
    - upload_local_directory(local_dir='./frames/', folder='scroll-sequences/hero')
    - 'CLI: spideriq media upload ./frames/ --folder scroll-sequences/hero'
    weight_budget:
      scroll_sequence_folders: 500 KB per file, 20 MB per batch (hard ceiling)
      general_folders: 20 MB per file, 500 MB per batch
      video_mime: 500 MB single-file (for raw source → video_to_scroll_sequence)
      over_budget_response: 'HTTP 400 with suggested_action (usually: enable auto_optimize)'
    anti_patterns:
    - DO NOT tunnel local files through pinggy/serveo/localhost.run into /media/files/import-url
      — tunnels inject HTML interstitials that land as .webp (black frames in scroll-sequences).
    - DO NOT upload 120 × 1.6 MB DSLR JPG frames without auto_optimize — you'll hit
      the 20 MB scroll-sequence batch ceiling and get a 400. Sharp auto-optimize turns
      192 MB into ~8 MB.
    - DO NOT upload to catbox.moe / raw.githubusercontent.com / third-party hosts
      and reference those URLs — no tenant isolation, no CDN caching, eventual link
      rot.
    recipe_skill: github.com/SpiderIQ/SpiderPublish/tree/main/designer-kit/skills/recipes/bulk-media-upload
    see:
    - media
  host_a_generated_image_url:
    when: you have an image at a URL (a generated hero from a provider host like kie.*,
      a stock image, ANY remote image) that you want to use as a blog post cover_image_url
      or a page image block — and you CAN'T upload a local file (you're an agent on
      a JSON-only forwarder, no multipart, no local disk).
    why: cover_image_url / page image fields only accept a small host allowlist (media.cdn.spideriq.ai
      + a few stock CDNs). A raw generation-host URL is REJECTED at the Pydantic boundary.
      The fix is to mirror the image onto R2 FIRST, then use the hosted URL.
    preferred_path: 'ONE call: media_ingest_url(media_url=''<your url>'', folder?=''blog'',
      alt_text?=''...''). The server fetches the URL SSRF-safely (5 MB cap, images
      only), transcodes to WebP, hosts it on R2, and returns {media:{r2_url}}. Pass
      that r2_url to content_create_post as cover_image_url — it now passes the allowlist.
      Needs @spideriq/mcp ≥1.40.0 / mcp-publish ≥1.26.0 / cli ≥1.30.0.'
    one_shot_examples:
    - media_ingest_url(media_url='https://kie.example/gen/hero-abc.png', folder='blog')
    - → then content_create_post(title=..., slug=..., body=..., cover_image_url='<returned
      r2_url>')
    - 'CLI: spideriq media import-url ''https://kie.example/gen/hero-abc.png'' --folder
      blog'
    - 'HTTP: POST /api/v1/dashboard/content/media/upload-from-url {"media_url":"..."}'
    anti_patterns:
    - DO NOT pass a raw provider/generation-host URL straight to cover_image_url —
      it 422s on the host allowlist. Ingest it first.
    - DO NOT try to allowlist the generation host — provider hosts rotate and have
      no retention guarantee (the catbox.moe outage is why the allowlist exists).
      Host on R2 instead.
    see:
    - media
    - publish_a_blog_post
    - upload_many_local_files
  preview_a_draft_post:
    when: you want a human to REVIEW a draft blog post before it's published. Draft
      posts 404 at /blog/<slug> on the live site (published-only), so there's no public
      URL to share — this mints one.
    preferred_path: content_preview_post(post_id) → returns {preview_url, expires_at}.
      The preview_url is tokened, noindex, and expires in 15 minutes; it renders the
      LATEST saved draft in the tenant's real theme. Hand it to your reviewer; once
      approved, content_publish_post(post_id).
    one_shot_examples:
    - 'content_preview_post(post_id=''550ccc08-…'')  # → https://app.spideriq.ai/api/v1/content/preview/<token>'
    - 'CLI: spideriq content posts:preview <post_id>'
    - 'HTTP: POST /api/v1/dashboard/content/posts/{post_id}/preview'
    notes:
    - Re-call to get a fresh URL after the 15-min expiry — the token is the whole
      credential.
    - Works for published posts too, but it's most useful for drafts (which otherwise
      404).
    - The reviewer needs no login — the token gates access.
    see:
    - publish_a_blog_post
  publish_a_blog_post:
    when: creating or publishing a blog article
    preferred_path: 'Three-step: create draft → attach author/tags/categories → publish.
      Blog post body uses Tiptap JSON (not HTML, not Markdown). Full templates ship
      in the default theme: blog.liquid lists posts, blog-post.liquid renders a single
      one via {{ post.body | tiptap_html }}.'
    steps:
    - 'content_create_author(full_name, slug?, avatar_url?, bio?, role?)  # once per
      author'
    - 'content_create_tag(name, slug?)  # once per tag (optional)'
    - 'content_create_category(name, slug?, parent_id?)  # once per category (optional,
      hierarchical)'
    - content_create_post(title, slug, body={'type':'doc','content':[...]}, excerpt?,
      cover_image_url?, author_id?, tag_ids?, category_ids?, is_featured?, seo_title?,
      seo_description?, vayapin_pins?)
    - 'content_publish_post(id)  # draft → published'
    - '# NO deploy required — blog post body renders live via the content API. Deploy
      only if you # ALSO changed templates/theme/config.'
    lifecycle: draft → published → archived (soft-delete via DELETE; POST /unpublish
      reverts to draft)
    body_format:
      type: Tiptap v2 JSON (ProseMirror document)
      root: '{type: ''doc'', content: [...]}'
      common_nodes:
      - paragraph
      - heading (level 1-6)
      - bullet_list
      - ordered_list
      - blockquote
      - code_block
      - image
      - horizontal_rule
      marks:
      - bold
      - italic
      - link (href)
      - code
      - underline
      - strike
      reading_time: auto-calculated on publish at 200 WPM — no manual field
    public_routes:
    - 'GET /content/posts?page=1&page_size=20[&tag=foo&category=bar]  # list'
    - GET /content/posts/featured?limit=10
    - GET /content/posts/search?q=...
    - 'GET /content/posts/{slug}  # increments view_count'
    - GET /content/authors / /content/authors/{slug}
    - GET /content/tags / /content/categories
    vayapin_pins: Optional list of VayaPin pin ids (e.g. ['BB:TAPAS','BB:CHAMPERS'])
      that 'fit' this article. blog-post.liquid auto-renders a 'VayaPins in this article'
      card strip (logo + title + link) at the bottom — no manual placement, no deploy.
      Look up valid pin ids first via GET /content/vayapin/cards?q=...&country=bb
      (or ?pins=BB:TAPAS to verify exact ids). Unknown / unlisted / non-public pins
      are silently skipped.
    anti_patterns:
    - DO NOT store HTML in `body` — it won't render. Use Tiptap JSON.
    - DO NOT redeploy the site after publishing — content renders live via the API.
    - DO NOT forget to publish the post — drafts don't appear on public /blog.
    - DO NOT set is_featured=true on more than a handful — /posts/featured is used
      for marquee slots.
    - DO NOT invent VayaPin pin ids for vayapin_pins — resolve them via GET /content/vayapin/cards
      first.
    see:
    - posts
    - tags
    - categories
    - authors
  build_a_directory:
    when: programmatic SEO — many per-city pages listing businesses in a category
      (e.g. 'plumbers in {city}')
    preferred_path: 'Two concepts: CATEGORIES (top-level verticals with SEO templates)
      + LISTINGS (individual businesses inside them, grouped by city). Create a category,
      bulk-import listings from an IDAP dump or SpiderMaps job, and the platform auto-generates
      /directory/{category}/ + /directory/{category}/{city}/ + /directory/{category}/{city}/{listing}
      pages with SEO title/description rendered from your templates.'
    steps:
    - directory_create_category(name='Plumbers', slug='plumbers', seo_title_template='Best
      {category} in {city} | Acme Directory', seo_description_template='Find top-rated
      {category} in {city}. Compare ratings, reviews, and contact info.')
    - '# Then import listings, ideally from IDAP/SpiderMaps results:'
    - 'directory_bulk_upsert_listings(category_slug=''plumbers'', listings=[{name,
      slug?, city, state?, phone?, website?, rating?, review_count?, data?: {hours:
      [...]}}, ...])'
    - '# No publish step — listings default to status=''published''. No deploy step
      — pages render live.'
    - '# Verify: curl /content/directory/categories/plumbers → list of cities with
      listing counts'
    seo_templates:
      placeholders: '{category}, {city}, {listing} — rendered server-side on every
        directory page'
      example_title: Best {category} in {city} | Acme
      example_description: Compare {category} in {city}. Ratings, reviews, hours,
        directions.
      sitemap: Every category, every (category,city), and every published listing
        gets a sitemap.xml entry automatically
    url_structure:
      category_hub: /directory/{category_slug}                         → cities list
      city_page: /directory/{category_slug}/{city_slug}             → listings in
        that city
      listing_page: /directory/{category_slug}/{city_slug}/{listing_slug} → single
        listing detail
      city_slug: LOWER(city + '-' + state), stripped of non-alphanumeric (e.g. 'Miami
        Beach' + 'Florida' → 'miami-beach-florida')
    listing_fields:
      required:
      - name
      common:
      - slug
      - description
      - city
      - state
      - country
      - address
      - phone
      - email
      - website
      - rating
      - review_count
      - latitude
      - longitude
      flexible: '`data` JSONB — stick hours, amenities, images, anything the SEO template
        needs'
      traceability: '`source_job_id` — UUID of the SpiderIQ job that produced this
        listing'
    ecosystem_integration:
      idap_flow: IDAP stores every business SpiderIQ has seen (SpiderMaps + SpiderSite
        + SpiderCompanyData merged). A bulk_upsert_listings call can drop an entire
        IDAP result set into a directory category — set source_job_id so you can audit
        provenance.
      spidermaps_flow: Run a SpiderMaps campaign → collect results → directory_bulk_upsert_listings(category_slug,
        listings=results). For large imports, paginate at 5000 per call.
      merge_tags: Listings use the same merge-tag pipeline as dynamic landing pages
        — any field you store in data JSONB can be surfaced in a custom template.
    anti_patterns:
    - DO NOT create a category per city — one category spans all cities. Cities are
      derived from listings.
    - DO NOT manually manage city_slug — the materialized view computes it from city
      + state.
    - DO NOT bulk-import more than 5000 listings in one call — paginate larger imports
      to avoid txn timeouts.
    - DO NOT bypass the bulk endpoint for IDAP dumps — individual upserts work but
      burn 100× the API budget.
    see:
    - directory
    - merge_tags
    - sitemap
  change_brand_color:
    when: one-field update — buttons, CTAs, accent borders
    tool: content_update_settings
    field: primary_color (hex like '#ff6600')
    note: primary_color is the ACCENT. Backgrounds use surface_color.
  add_a_custom_component:
    when: you need reusable HTML+CSS+JS with CDN dependencies
    steps:
    - content_create_component(slug, html_template, css, js?, dependencies?=['gsap',
      'swiper', ...], props_schema?={...})
    - content_publish_component(id)
    - Add {type:'component', component_slug:<slug>, props:{...}} block to any page
    tiers:
      tier_1: HTML + CSS only. No JS, no deps.
      tier_2: HTML + CSS + scoped JS (auto-executed in Shadow DOM).
      tier_3: HTML + CSS + JS + CDN deps from the allowlist. See tier3_cdn_allowlist
        for the 10 available libraries (GSAP, anime.js, Three, Lottie, Swiper, Chart.js,
        etc.).
      tier_4: React/Vue/Svelte source, compiled via esbuild, deployed to R2, loaded
        as ES module.
    see:
    - components
    - tier3_cdn_allowlist
  deploy_to_production:
    when: changes are ready — DRAFT state doesn't render on live site
    steps:
    - content_deploy_preview() → returns { preview_url, confirm_token, snapshot_hash
      }
    - Review preview_url (live staging) or check the `preview` diff in the response
    - 'content_deploy_production(confirm_token) → returns { status: ''live'', version_id
      }'
    rollback: No direct rollback once consumed. Re-deploy a previous version_id via
      a new preview+confirm cycle.
    see:
    - deploy_workflow
  set_up_a_custom_domain:
    when: client-branded domain (custom TLD or subdomain)
    steps:
    - content_add_domain(domain='mail.example.com')
    - Client adds CNAME → sites.spideriq.ai (for out-of-account zones) OR nothing
      required (if zone already in our CF account)
    - content_verify_domain(domain) — polls CF cert status
    - content_set_primary_domain(domain) — becomes the canonical URL
    - Deploy — Worker Route + KV mapping auto-created
    see:
    - domains
  build_a_page_with_blocks:
    when: creating a CMS page from scratch or adding blocks to an existing page
    canonical_block_shape:
      id: required unique string (UUIDv4 or any stable ID)
      type: 'required — one of BlockType enum: component, rich_text, hero, features_grid,
        stats_bar, cta_section, pricing_table, testimonials, faq, code_example, logo_cloud,
        comparison_table, image, video_embed, spacer'
      data: 'type-specific JSON (e.g. for rich_text: {html: ''<p>...</p>''} OR {content:
        <Tiptap JSON>}; for native-typed blocks: the data the block renderer needs)'
      component_slug: REQUIRED when type='component' — the slug of a published component
        to render this block with Shadow DOM isolation. Putting this in `data.slug`
        will 422.
      component_version: optional pinned version for type='component' (omit for latest
        published)
      props: optional dict passed to the component template
    examples:
      component_block:
        id: b1
        type: component
        component_slug: hero-gradient-v1
        component_version: 1.0.0
        props:
          headline: Welcome
          cta_text: Start
      rich_text_block:
        id: b2
        type: rich_text
        data:
          html: <p>Legal copy here.</p>
      rich_text_raw_html_isolated:
        id: b3
        type: rich_text
        data:
          html: <h1 style="margin:0 auto;text-align:center">Hand-built section</h1>
          no_prose: true
        why: 'By default your raw `data.html` renders INSIDE the theme''s `prose`
          typography — which resets h1/p margins, recolors inline <code>, restyles
          links, and (via prose-invert) forces light-on-dark text that''s invisible
          on a light theme. Your isolated standalone preview looks right; the live
          page bleeds. Opt out per-block: `data.no_prose:true` drops the prose wrapper
          (set explicit styles and they survive); `data.reset:true` adds `all:revert`
          isolation from the theme''s bare element selectors too (your inline styles
          still win); `data.full_width:true` drops the centered max-width container.
          Defaults are unchanged. Tiptap content (`data.content`) keeps prose unless
          you also pass these flags.'
    anti_patterns:
    - '`{type: ''component'', data: {slug: ''x'', props: {}}}` — returns 422 since
      2026-04-24. Move `slug` to top-level `component_slug`.'
    - '`rich_text` with `data: {text: ''...''}` — returns 422 since 2026-04-24. Use
      `data.html` (raw HTML) or `data.content` (Tiptap JSON).'
    - Unknown fields like `css_styles` on component PATCH — silently ignored but surfaced
      in `warnings[]` response since 2026-04-24. Check the response body.
    - Slashes in `slug` (e.g. `product/xyz`) — returns 422 since 2026-04-24. Use flat
      slugs like `product-xyz`. Nested docs use `parent_id` chains, not `/`.
    see:
    - components
    - shadow_dom_conventions
  shadow_dom_conventions:
    when: writing / debugging a component whose Shadow DOM styling looks wrong
    rules:
    - 'Every content component should set `:host { background-color: ... }` explicitly
      — the default body is `slate-950` / #0A0A0B (dark). Override site-wide via content_settings.surface_color.'
    - font-family does NOT inherit into Shadow DOM. Declare it in the component's
      `css` field, or rely on the theme CSS variables injected into `:host` by the
      renderer.
    - 'Global `max-width: 1280px` container doesn''t bleed into Shadow DOM. Use an
      inner `.inner { max-width: 1280px; margin: 0 auto; }` wrapper INSIDE the component
      template.'
    - External `<link rel='stylesheet'>` inside the Shadow DOM is silently ignored.
      Inline the CSS into the `css` field, or for Tilda imports pass `auto_extract_css=true`
      on component create/update.
    - Inline `<style>` blocks in `html_template` are REJECTED with a 400 — the Liquid
      renderer injects the `css` field via Shadow DOM and ignores inline styles. Use
      `auto_extract_css=true` for one-shot bulk extraction.
    - Modals / drawers needing to escape the shadow host should render at body level
      via component JS + `document.body.appendChild`, not inside the shadow root.
    - Components with `category='header'` or `category='footer'` auto-suppress the
      native theme chrome (2026-04-24). No more double-header workarounds.
    - 'Empty-string props now correctly suppress `default_props` (2026-04-24). `{image:
      ''''}` in block props falls through to Liquid `{% if props.image %}` as falsy.'
    verify_visually: Use `component_preview(component_id, props)` (2026-04-24) to
      iframe-render a single component in isolation without a full site deploy.
    see:
    - components
    - deploy_fast_as_agent
  migrate_from_tilda:
    when: porting a Tilda site to SpiderPublish (SMS-Chemicals / Onyx / Di-Atomic
      pattern)
    steps:
    - 1. Export HTML from Tilda (download zip, or Tilda API with TILDA_PUBLIC_KEY/TILDA_PRIVATE_KEY).
    - '2. For every HTML section that will become a component: call `component_create(slug=...,
      html_template=..., css=..., auto_extract_css=true)`. The server moves inline
      `<style>` blocks into `css` automatically.'
    - 3. Download any referenced external CSS from `static.tildacdn.one` via `curl`
      and concat into the component's `css` field (external `<link>` inside Shadow
      DOM is silently ignored — you MUST inline).
    - 4. Upload images via `upload_local_directory(local_dir=..., folder='tilda-migration/')`.
    - '5. Create pages with `content_create_page(slug=..., blocks=[{id, type: ''component'',
      component_slug, component_version, props}, ...])`. Slugs MUST be flat (no `/`).'
    - '6. For shared headers/footers: one component per site with `category=''header''`
      / `''footer''` so the native chrome auto-suppresses. Use `component_update_and_propagate(slug,
      ..., pages=[])` to roll changes across all pages.'
    - 7. `content_deploy_preview()` → review preview URL → `content_deploy_production(confirm_token)`
      to go live. Use `--yolo` on CLI for iterative tweaks (skips the confirm step).
    proven_references: SMS-Chemicals (sms-chemicals.com), Di-Atomic (di-atomic.com),
      Onyx Radiance (onyx-radiance.com).
    anti_patterns:
    - Passing HTML with `<style>` blocks without `auto_extract_css=true` — returns
      400.
    - Relying on external `<link rel='stylesheet'>` inside Shadow DOM — silently ignored
      at render time.
    - Nested slugs like `product/pillowcase` — returns 422 since 2026-04-24.
    - Manually iterating all component versions to update shared header/footer — use
      `component_update_and_propagate` instead.
    see:
    - shadow_dom_conventions
    - build_a_page_with_blocks
    - update_component_site_wide
  deploy_fast_as_agent:
    when: iterating on small style/copy changes where the full preview→confirm cycle
      is noise
    modes:
      interactive_preview: 'Default: `content_deploy_preview()` → review `preview_url`
        → `content_deploy_production(confirm_token)`. Phase 11+12 Lock 4 — safest.'
      yolo_cli: '`spideriq content deploy --yolo` skips preview and deploys straight
        to production atomically. Good for copy-edit loops where preview is overhead.
        No snapshot diff; no human confirmation step.'
      prefetched_token: '`spideriq content deploy --confirm <token>` consumes a pre-issued
        confirm_token for scripted automation (non-interactive CI).'
      component_preview: '`component_preview(component_id, props)` (2026-04-24) returns
        the Shadow-DOM-wrapped HTML + CSS + JS + merged_props so the dashboard can
        iframe-render a single component. Quick visual check without a full deploy
        — 100-300ms instead of 60-90s.'
      link_audit: '`content_audit_links()` (2026-04-24) validates every internal link
        across pages + nav against the published roster + redirects. Run before deploy
        to catch broken links.'
    when_to_use_which: Interactive preview for anything a human wants to eyeball.
      --yolo for copy edits you've already verified on dev. Confirm-token for CI.
      component_preview for Shadow DOM / layout tweaks. link_audit before shipping
      a nav reorganization.
    see:
    - deploy_workflow
    - components
  add_static_component:
    summary: Static component — props in, HTML out. No JS, no data binding.
    when: you need a marketing block (hero, feature grid, social proof) with no client-side
      behaviour and no data fetching
    required_fields:
    - slug
    - name
    - html_template
    - css
    - props_schema
    kind: static
    allowed_values:
      mood:
      - calm
      - energetic
      - bold
      - confident
      - dreamy
      - futuristic
      - urban
      - minimal
      - warm
      - sensory
      - editorial
      - professional
      - friendly
      - clear
      - technical
      - credible
      brand_fit_tags:
      - saas
      - agency
      - ecommerce
      - fintech
      - real-estate
      - hospitality
      - restaurant
      - wellness
      - healthcare
      - blog
      - publication
      - personal
      - tech
      - design
      - consulting
      - outdoor
      - lifestyle
      scene_type:
      - hero-bold
      - feature-grid
      - pricing-tiers
      - social-proof
      - faq-accordion
      - conversion-cta
      - navigation-header
      - navigation-footer
      - data-collection-form
      - editorial-content
      - team-grid
      - city-aerial
      - nature-landscape
      - abstract-motion
      - food-prep
      - people-lifestyle
      - tech-hardware
      - marketing-site
      - docs-site
      - directory-site
      - portfolio-site
    example_payload:
      kind: static
      slug: hero-minimal
      name: Minimal Hero
      html_template: <section class="hero"><h1>{{ heading }}</h1></section>
      css: :host{display:block;background:#fff}.hero{padding:6rem 2rem}
      props_schema:
        type: object
        properties:
          heading:
            type: string
        required:
        - heading
      default_props:
        heading: Welcome
      mood:
      - minimal
      - clear
      brand_fit_tags:
      - saas
      - agency
      scene_type: hero-bold
      preview_thumbnail_url: https://media.cdn.spideriq.ai/marketplace/<slug>.webp
    preview_image:
      required_when_global: true
      formats:
      - png
      - webp
      max_size_mb: 5
      dimensions_px: 320x180 (16:9 recommended)
      upload_endpoint: POST /dashboard/content/components/{id}/upload-preview
      fallback: PlaceholderThumbnail.tsx renders a kind-specific SVG when preview_thumbnail_url
        IS NULL.
    common_mistakes:
    - Setting js_runtime — Static components MUST have js_runtime=null (constraint
      enforced).
    - Including a data_binding field — Static doesn't fetch data; that's Dynamic.
      Validator rejects.
    - Skipping :host { background-color } in CSS — components render invisible on
      dark themes (catalog LEARNINGS 'Shadow DOM dark body leaks through').
    - Forgetting preview_thumbnail_url on is_global=true — admin Component Library
      renders broken-image placeholders.
    see:
    - marketplace.universal_axes
    - marketplace.endpoints
    - components
  add_interactive_component:
    summary: Interactive component — props + browser JS, NO data fetch (cookies, timers,
      popups, scroll listeners).
    when: you need a UI element with client-side behaviour (timer, popup, accordion,
      scroll-driven animation) but no server data
    required_fields:
    - slug
    - name
    - html_template
    - css
    - js
    - js_runtime
    - props_schema
    kind: interactive
    allowed_values:
      js_runtime: &id001
      - vanilla
      - web-component
      - island
      - none
      interaction_pattern: &id002
      - static
      - click
      - hover
      - scroll
      - timer
      - form
      - drag
      trigger_kind:
      - page-load
      - scroll-into-view
      - click
      - hover
      - exit-intent
      - timer-fixed-date
      - timer-elapsed
      - form-submit
      - geo-match
      - none
      placement:
      - above-fold
      - below-fold
      - side-rail
      - modal
      - toast
      - footer
      - header
      - any
      conversion_strategy:
      - primary-cta
      - secondary-cta
      - trust
      - scarcity
      - social-proof
      - education
      - navigation
      - none
    example_payload:
      kind: interactive
      slug: sys-timer-countdown
      name: Countdown Timer
      html_template: <div class="timer" data-target="{{ target_iso }}"><span class="d"></span></div>
      css: :host{display:block}.timer{font-variant-numeric:tabular-nums}
      js: // vanilla — read data-target, tick every 1s, write innerText
      js_runtime: vanilla
      props_schema:
        type: object
        properties:
          target_iso:
            type: string
            format: date-time
        required:
        - target_iso
      agent_meta:
        interaction_pattern: timer
        trigger_kind: timer-fixed-date
        placement: above-fold
        motion_safety: true
      preview_thumbnail_url: https://media.cdn.spideriq.ai/marketplace/<slug>.webp
    preview_image:
      required_when_global: true
      formats:
      - gif
      - webp
      - png
      max_size_mb: 5
      dimensions_px: 320x320 (square; 3-second loop preferred)
      upload_endpoint: POST /dashboard/content/components/{id}/upload-preview
      fallback: Static PNG of the rest-state UI also acceptable.
    common_mistakes:
    - Network fetch inside js — that promotes the component to Dynamic. Use kind='dynamic'
      + sources/data_binding instead.
    - Missing motion_safety in agent_meta — if your JS animates, set agent_meta.motion_safety=true
      (honours prefers-reduced-motion).
    - Inline <script> tags inside html_template — engine strips them. Put the JS in
      the js field with the right js_runtime.
    - Setting js_runtime='none' on Interactive — then it has no behaviour and should
      be kind='static'.
    see:
    - marketplace.agent_meta_keys.component
    - components
  add_dynamic_component:
    summary: Dynamic component — fetches a collection or record at render time via
      data_binding.
    when: the block iterates rows from a content_data_sources entry (posts, authors,
      idap.businesses) or renders one record by id/slug
    required_fields:
    - slug
    - name
    - block_type
    - layouts
    - sources
    kind: dynamic
    allowed_values:
      block_type:
      - list
      - item_details
      - form
      - calendar
      - kanban
      - chart
      - map
      - table
      js_runtime: *id001
      interaction_pattern: *id002
    example_payload:
      kind: dynamic
      slug: list
      name: List Block
      block_type: list
      layouts:
      - id: stacked
        name: Stacked
        description: Vertical column.
      - id: columned
        name: Columned
        description: Two-column grid.
      sources:
      - source_id: posts
        default: true
      - source_id: authors
      - source_id: idap.businesses
      html_template: '{% for item in items %}<article><h3>{{ item.title }}</h3></article>{%
        endfor %}'
      css: :host{display:block}article{padding:1rem}
      agent_meta:
        interaction_pattern: static
        placement: below-fold
    preview_image:
      required_when_global: true
      formats:
      - png
      - webp
      max_size_mb: 5
      dimensions_px: 320x180 (16:9; show the block rendered with sample data)
      upload_endpoint: POST /dashboard/content/components/{id}/upload-preview
      fallback: PlaceholderThumbnail.tsx renders a grid icon for kind=dynamic.
    common_mistakes:
    - Hardcoding the data source id in html_template — sources[] is the registry-driven
      list; data_binding picks one at insert time.
    - Forgetting to register the source in content_data_sources — block validates
      on insert (constraint chk_components_dynamic_has_sources).
    - 'Setting block_type to a value not in DynamicBlockType — validator rejects.
      Allowed: list, item_details, form, calendar, kanban, chart, map, table.'
    - Trying to PATCH a 'dm-blog-listing' that doesn't exist — see /content/help →
      tasks.customize_blog for the template-based blog override path.
    see:
    - marketplace.agent_meta_keys.component
    - data_sources_registry
    - components
  add_extension_component:
    summary: Extension component — needs a Worker route, renderer hook, or MCP server.
      NOT a normal Liquid component.
    when: the block requires server-side secrets (tracking relay), a per-tenant URL
      pattern (worker-route), or external data the renderer can't fetch directly (MCP
      server)
    required_fields:
    - slug
    - name
    - extension_spec
    kind: extension
    allowed_values:
      extension_pattern:
      - renderer-hook
      - worker-route
      - mcp-server
      - client-side-pixel
    example_payload:
      kind: extension
      slug: sys-tracking-fb-capi
      name: Facebook CAPI Tracking Relay
      extension_spec:
        pattern: worker-route
        requires_tenant_route_registration: true
        params:
          route_path: /track
          http_methods:
          - POST
          secrets_required:
          - FB_PIXEL_ID
          - FB_ACCESS_TOKEN
        depends_on:
        - consent-banner
      html_template: <!-- extension renders no HTML by default -->
      css: ''
      agent_meta:
        interaction_pattern: static
        placement: any
    preview_image:
      required_when_global: true
      formats:
      - png
      - webp
      max_size_mb: 5
      dimensions_px: 320x240 (4:3; schematic data-flow diagram preferred — extensions
        are often invisible)
      upload_endpoint: POST /dashboard/content/components/{id}/upload-preview
      fallback: PlaceholderThumbnail.tsx renders a plug icon for kind=extension.
    common_mistakes:
    - Putting tenant secrets in extension_spec.params.secrets_required values — that
      field declares WHICH secrets the tenant must provide; the values live in the
      per-tenant vault, NEVER in the catalog row.
    - Skipping depends_on for tracking-relay extensions — most need consent-banner
      active first; declare it.
    - Setting requires_tenant_route_registration=false on pattern='worker-route' or
      'mcp-server' — validator rejects (those patterns ALWAYS require route registration).
    - Treating Extension as a normal component — it has no html_template behaviour
      beyond the placeholder; the work happens in the Worker route or hook.
    see:
    - marketplace.agent_meta_keys.component
    - components
    - worker_routes
page_templates:
  description: The `template` field on content_pages selects which Liquid template
    file renders the page. Unknown values fall back to 'default' silently.
  values:
    default: Standard page with header + footer + default body classes. Most pages
      use this.
    landing: Like default, but main content is full-bleed (no max-width container).
      Good for marketing pages with full-width sections.
    blank: No header, no footer, no default body classes, no layout wrapper. Complete
      freedom — use for landing pages with a custom hero that paints the whole viewport.
    dynamic_landing: For /lp/ routes only. Populated with lead + salesperson data
      from IDAP.
  set_via: POST /dashboard/content/pages or PATCH /dashboard/content/pages/{id} with
    field `template`
  per_client_overrides: Any client can upload their own templates/<name>.liquid via
    content_templates and they take precedence over the default theme. See §chrome_override
    for the override workflow.
theme_palette:
  description: Surface + accent colors configurable per-site via content_settings.
    Null values fall back to the canonical dark palette.
  fields:
    primary_color: 'Accent / CTA / link color. Default #eebf01 (SpiderIQ yellow).'
    surface_color: 'Body / main background. Default #0A0A0B (near-black).'
    surface_elevated_color: 'Card / panel background. Default #111113.'
    subtle_color: 'Border / subtle background. Default #1A1A1D.'
    body_text_color: 'Default body text. Default #e5e5e5.'
    heading_color: 'Headings / logo text. Default #ffffff.'
  css_variables_exposed:
  - --primary
  - --primary-rgb
  - --surface
  - --surface-elevated
  - --subtle
  - --body-text
  - --heading
  make_light_example:
    surface_color: '#ffffff'
    surface_elevated_color: '#f5f5f5'
    subtle_color: '#e5e5e5'
    body_text_color: '#18181b'
    heading_color: '#0a0a0a'
    primary_color: '#3b82f6'
  note: primary_color is a STRING accent, NOT a mode-switch. Setting primary_color='#000000'
    does NOT make the site dark — use the surface_* fields for that.
chrome_override:
  description: Per-client overrides for theme files (header, footer, layout, head,
    any Liquid template or asset). Uploaded rows in content_templates take precedence
    over the default theme at render time.
  tools:
    read_current: content_get_section_source(section='header'|'footer'|'layout'|'head')
    upload_override: content_override_section(section, liquid) OR template_upsert(path='sections/footer.liquid',
      content=liquid)
    apply_preset: content_apply_layout_preset(preset='default'|'blank'|'landing')
  typical_paths:
  - layout/theme.liquid — wrapping document structure
  - sections/header.liquid — site header
  - sections/footer.liquid — site footer
  - sections/hero.liquid — hero section (if used)
  - snippets/head.liquid — <head> contents
  - snippets/post-card.liquid — blog post card (used by blog listing + related posts)
  - assets/theme.css — custom CSS (served at /_assets/theme.css)
  - templates/page.liquid — page template
  - templates/landing.liquid — landing-page template
  - templates/blank.liquid — no-chrome template
  - templates/blog.liquid — blog listing layout (override to restyle /blog index)
  - templates/blog-post.liquid — single blog post layout (override to restyle /blog/{slug})
  blog_overrides:
    description: Blog templates ARE supported by content_override_section as of 2026-04-30
      — use section_slug='blog-listing' or section_slug='blog-post'. Underneath this
      is just template_upsert to templates/blog.liquid / templates/blog-post.liquid;
      the engine merges per-client KV overrides over the bundled default theme automatically.
      There is NO `dm-blog-listing` or other component for the blog — the blog UI
      is template-based.
    workflow_easy:
    - 1. content_get_section_source(section_slug='blog-listing') → current blog.liquid
      (bundled or override)
    - 2. Modify the Liquid in your own context
    - 3. content_override_section(section_slug='blog-listing', liquid_source=modified)
    - 4. content_deploy_preview() → content_deploy_production(confirm_token)
    workflow_underlying_api:
    - 1. template_get(path='templates/blog.liquid') → bundled default if no override
      exists
    - 2. Modify the Liquid in your own context
    - 3. template_upsert(path='templates/blog.liquid', content=modified)
    - 4. content_deploy_preview() → content_deploy_production(confirm_token)
    block_composition_alternative: 'If you''d rather compose blocks (hero / FAQ /
      footer) on /blog instead of writing Liquid: content_create_page(slug=''blog'',
      template=''default'', blocks=[...]) → publish → deploy. The renderer prefers
      a CMS page at slug ''blog'' over the hardcoded template. Tag pages (/blog/tag/{tag})
      keep the legacy listing.'
    cli_equivalent: spideriq templates set 'templates/blog.liquid' --file ./blog.liquid
    see_also:
    - templates/blog-post.liquid for single-post layout (section_slug='blog-post')
    - snippets/post-card.liquid for the card component reused by blog listing + related-posts
    - assets/theme.css for blog-specific CSS (no separate blog.css)
    - §customize_blog task entry above for the full goal-oriented walkthrough
  workflow:
  - 1. content_get_section_source(section='footer') → returns { source, is_override
    }
  - 2. Modify the returned Liquid in your own context
  - 3. content_override_section(section='footer', liquid=modified)
  - 4. content_deploy_preview() → content_deploy_production(confirm_token)
  live_examples:
  - danmagi.com — overrides layout/theme.liquid + sections/header.liquid + sections/footer.liquid
  - sms-chemicals.com — same pattern
  - mail.spideriq.ai — same pattern
  do_not:
  - Build JavaScript Shadow-DOM-escape hacks (document.querySelector('body > footer').style.X
    = ...). Use this override system instead.
  - Create a component with slug='footer' expecting it to replace the default footer.
    Components and sections are different subsystems.
tier3_cdn_allowlist:
  description: When a component declares dependencies, the worker injects matching
    <script>/<link> tags into <head> with SRI hashes. Only the 10 allowlisted keys
    can be used.
  keys:
    gsap: GSAP Core 3.12 — animation library
    gsap/ScrollTrigger: GSAP ScrollTrigger — scroll-driven animations (requires gsap)
    gsap/Flip: GSAP Flip — FLIP layout transitions (requires gsap)
    animejs: anime.js 3.2 — lightweight alternative to GSAP
    alpinejs: Alpine.js 3 — minimal reactive framework
    chartjs: Chart.js 4 — canvas charting
    lottie: Lottie Web 5 — After Effects animation player
    swiper: Swiper 11 — touch carousel (+ CSS)
    countup: CountUp.js 2 — animated number counter
    three: Three.js 0.162 — WebGL 3D
  usage: content_create_component(slug='my-hero', dependencies=['gsap', 'gsap/ScrollTrigger'],
    ...)
  not_allowlisted:
    framer_motion: React-only library, incompatible with Tier 3 CDN injection. For
      Framer-Motion-style APIs in pure HTML, ask for `motion.dev` (Motion One) to
      be allowlisted. For React components, use Tier 4.
session_binding:
  description: Every dashboard-scoped call must target a specific project. The CLI/MCP
    automatically injects /projects/{project_id}/ into URLs when a `spideriq.json`
    file is present in (or above) the current working directory — same pattern as
    `vercel link`.
  file: spideriq.json
  location: repo root (commit to VCS)
  shape:
    project_id: string (cli_xxx short form)
    project_name: string (optional, display only)
    api_url: string (optional, override)
    created_at: string (ISO-8601)
  commands:
    bind: 'spideriq use <project_id_or_name>  # writes spideriq.json'
    list: 'spideriq use --list  # lists accessible projects'
    inspect: 'spideriq whoami  # shows current session binding + PAT scope'
  url_form_with_binding: POST /api/v1/dashboard/projects/{project_id}/content/...
  url_form_legacy: 'POST /api/v1/dashboard/content/...  # deprecated; stamped with
    Deprecation: true header'
  locks_enforced:
  - 'Lock 1 (token scope): PAT.client_id must match URL project_id'
  - 'Lock 2 (URL scope): URL project_id drives resource lookup'
  - 'Lock 3 (session): spideriq.json per cwd keeps two windows from cross-wiring'
  - 'Lock 5 (resource ownership): every resource must belong to URL project_id'
  cross_tenant_attempt_response: 403 Forbidden — written to content_tenant_audit with
    failed_lock=token_vs_url
deploy_workflow:
  description: Destructive operations (publish, unpublish, delete, update_settings,
    apply_theme, delete/publish/archive_component, deploy) are gated by a two-step
    preview → confirm flow. The first call with dry_run=true returns a confirm_token;
    the second call consumes it to mutate.
  gated_operations:
  - DELETE /dashboard/projects/{pid}/content/pages/{page_id}
  - POST   /dashboard/projects/{pid}/content/pages/{page_id}/publish
  - POST   /dashboard/projects/{pid}/content/pages/{page_id}/unpublish
  - PATCH  /dashboard/projects/{pid}/content/settings
  - POST   /dashboard/projects/{pid}/templates/apply-theme
  - DELETE /dashboard/projects/{pid}/content/components/{id}
  - POST   /dashboard/projects/{pid}/content/components/{id}/publish
  - POST   /dashboard/projects/{pid}/content/components/{id}/archive
  - POST   /dashboard/projects/{pid}/content/deploy/preview
  - POST   /dashboard/projects/{pid}/content/deploy/production
  step_1_preview:
    request: call the endpoint with ?dry_run=true
    response_envelope:
      dry_run: 'true'
      action: string (e.g. delete_page)
      resource_id: string | null
      preview: object (describes the would-be mutation)
      confirm_token: cft_<32 hex>
      expires_at: ISO-8601 (default 7 days)
      snapshot_hash: sha256 of preview payload
  step_2_confirm:
    request: call the endpoint with ?confirm_token=<token from step 1>
    response: normal endpoint response — the mutation executed
  deploy_specifics:
    preview_endpoint: POST /dashboard/projects/{pid}/content/deploy/preview
    production_endpoint: POST /dashboard/projects/{pid}/content/deploy/production?confirm_token=cft_...
    preview_url: preview-{cid4}-{hash8}.sites.spideriq.ai — serves the staging snapshot
      for 7 days
    rollback_after_consume: not yet supported; re-deploy a previous version_id via
      a new preview
  error_responses:
    '403': TokenInvalid / TokenClientMismatch / TokenActionMismatch / TokenResourceMismatch
      — the token doesn't match what you're trying to do
    '409': TokenConsumed — token was already used once (single-use)
    '410': TokenExpired — past expires_at; issue a new dry_run
  mcp_tool_defaults: Destructive MCP tools default to dry_run=true when neither flag
    is passed. Agents receive the preview envelope on the first call and MUST call
    again with confirm_token to mutate.
  cli_flags:
    --dry-run: issue preview without mutating
    --confirm <token>: consume a prior preview and execute
    --yolo: skip preview entirely (CI mode; audit event emitted)
    --json: emit machine-readable envelopes instead of interactive prompts
content_types:
  pages:
    description: Marketing pages composed of blocks
    fields:
      slug: string (URL path, required)
      title: string (required)
      description: string (optional)
      blocks: array of ContentBlock (see block_types below)
      template: string (default, landing, feature, legal, dynamic_landing)
      seo_title: string (max 200)
      seo_description: string (max 500)
      og_image_url: string
      json_ld: object (structured data)
      custom_fields: object (arbitrary JSONB data)
    status_flow: draft → published → archived
    api:
      list: GET /content/pages
      get: GET /content/pages/{slug}
      create: POST /dashboard/content/pages
      update: PATCH /dashboard/content/pages/{id}
      publish: POST /dashboard/content/pages/{id}/publish
  posts:
    description: Blog posts with Tiptap rich text body
    fields:
      slug: string (URL path, required)
      title: string (required)
      body: object (Tiptap JSON document, required)
      excerpt: string
      cover_image_url: string
      author_name: string (max 200)
      tags: array of strings
      category_ids: array of UUIDs
      seo_title: string (max 200)
      seo_description: string (max 500)
    auto_fields:
      reading_time: int (calculated from body, 200 wpm)
    api:
      list: GET /content/posts
      get: GET /content/posts/{slug}
      create: POST /dashboard/content/posts
      publish: POST /dashboard/content/posts/{id}/publish
  docs:
    description: Documentation with hierarchical tree structure
    fields:
      slug: string (required)
      title: string (required)
      body: object (Tiptap JSON document, required)
      parent_id: UUID (for nesting)
      is_section: bool (folder-like node, no standalone page)
      sort_order: int
    auto_fields:
      full_path: string (computed, e.g. 'api/authentication/oauth')
    api:
      tree: GET /content/docs/tree
      get: GET /content/docs/{full_path}
      create: POST /dashboard/content/docs
  navigation:
    description: Header, footer, and docs sidebar menus
    locations:
    - header
    - footer
    - docs_sidebar
    item_fields:
      label: string (required)
      url: string
      icon: string
      is_external: bool (opens in new tab)
      badge: string (e.g. 'New', 'Beta')
      children: array of NavItem (recursive)
      source: NavSource or null — null/absent means hand-authored. Set it to render
        this item from the page tree instead.
    item_modes:
      hand_authored:
        when: no `source` — the original behaviour, unchanged
        example:
          label: Pricing
          url: /pricing
      site_bound:
        when: source.kind = 'site' — the whole page tree
        example:
          label: Browse
          source:
            kind: site
            depth: 2
      folder_bound:
        when: source.kind = 'folder' — one folder's published descendants
        example:
          label: Guides
          source:
            kind: folder
            folder_id: <uuid>
            depth: 2
    source_fields:
      kind: '''site'' | ''folder'' (required)'
      folder_id: uuid — REQUIRED when kind='folder' (422 without it)
      depth: int 1-3, default 2 — levels expanded BELOW the bound item
    expansion: A bound item is expanded server-side into `children` on every PUBLIC
      read (GET /content/navigation/{location}), so publishing, renaming, reordering
      or archiving a page updates the live menu with no menu edit. The DASHBOARD read
      (GET /dashboard/content/navigation/{location}) returns the binding UN-expanded
      on purpose — you edit and round-trip the binding, not a snapshot of it. Empty
      `children` on a bound item there is expected, not an empty menu. Only PUBLISHED,
      non-folder descendants are ever emitted, so drafts cannot leak into a public
      menu.
    folder_index_page: 'A folder node links to its own index page: `index_page_id`
      if that page is still a published direct child, else the folder''s first published
      child by sort_order, else nothing — in which case the item''s `url` is null
      and themes render it as a label-only group header rather than a dead link. Set
      the override with PATCH /dashboard/content/pages/{id} {index_page_id: <uuid>}
      (folders only; 400 on a non-folder page or a self-reference). It self-heals:
      unpublish, archive or move the target out of the folder and resolution falls
      back silently.'
    folders: 'Create one with POST /dashboard/content/pages {is_folder: true} — an
      organizational node with no live URL, never rendered, sitemapped or listed in
      llms.txt. Fill it by PATCHing children with {parent_id: <folder uuid>}. Discover
      folder ids from GET /dashboard/content/pages — rows carry `is_folder` and `parent_id`.'
    api:
      get: GET /content/navigation/{location}
      update: PUT /dashboard/content/navigation/{location}
  settings:
    description: Site-wide branding and configuration
    fields:
      site_name: string
      site_tagline: string
      primary_color: 'string (hex, default #eebf01)'
      logo_dark_url: string (URL)
      logo_light_url: string (URL)
      favicon_url: string (URL)
      copyright_text: string
      social_links: 'object ({platform: url})'
      google_analytics_id: string (G-XXXXX)
      plausible_domain: string
      default_og_image_url: string
      default_seo_title_suffix: string (max 100, appended to all titles)
      custom_head_scripts: string (injected in <head>)
    api:
      get: GET /content/settings
      update: PATCH /dashboard/content/settings
  changelog:
    description: Version-tracked release notes
    fields:
      version: string (e.g. '1.0.0', required)
      title: string (required)
      body: object (Tiptap JSON)
  press_releases:
    description: Newsroom press releases — a first-class content type with its own
      contacts, boilerplates and media kits. See the top-level `press_newsroom` section
      for the full model.
    fields:
      slug: string (URL-safe, required)
      title: string (headline, required)
      subheadline: string (deck)
      body: 'object (Tiptap JSON — or send {''markdown'': ''...''} / {''html'': ''...''})'
      release_type: enum (press_release | statement | media_alert | newsbyte)
      dateline_city: string (e.g. 'BERLIN')
      dateline_date: string (YYYY-MM-DD)
      boilerplate_id: uuid (the 'About <company>' tail to append)
      media_kit_id: uuid (media kit to attach)
      contact_ids: array of uuid (press contacts to list on the release)
      hero_image_url: string
      legal_disclaimer: string (forward-looking-statements tail)
      is_featured: bool (pin to the top of the newsroom index)
    status_flow: draft → scheduled → published → archived (unpublish returns a published
      release to draft)
  components:
    description: Reusable UI components with automatic Shadow DOM isolation. CSS cannot
      leak between components. Components support 4 interactivity tiers.
    tiers:
      tier_1_static: HTML + CSS only. No JS. Default when js field is absent/null.
      tier_2_interactive: HTML + CSS + scoped vanilla JS. Set the js field. JS executes
        inside shadow root via new Function('root','props', code)(shadowRoot, props).
        A hydration script is auto-injected once at </body>.
      tier_3_rich: Tier 2 + CDN library dependencies. Set dependencies array with
        allowlist keys (e.g. ['gsap', 'chartjs']). Libraries loaded in <head>, deduplicated
        per page. GET /content/cdn-allowlist for available keys.
      tier_4_app: Framework component (React/Vue/Svelte). Set framework + source_code.
        On publish, esbuild bundles into web component <spideriq-app-{slug}> stored
        on R2. Publish returns 202 (async build). Poll build-status endpoint.
    tier_detection: Automatic — no explicit tier field. html_template+css=Tier1, +js=Tier2,
      +dependencies=Tier3, +framework+source_code=Tier4.
    fields:
      slug: string (URL-safe identifier, required)
      name: string (display name, required)
      description: string
      version: string (semver, default '1.0.0')
      category: enum (hero, cta, faq, pricing, features, testimonials, contact_form,
        footer, header, gallery, stats, custom)
      html_template: string (Liquid HTML template, required for Tier 1-3)
      css: string (component CSS — isolated via Shadow DOM)
      js: string (vanilla JS scoped to shadow root — receives 'root' and 'props' arguments.
        Tier 2+)
      dependencies: array of strings (CDN allowlist keys, e.g. ['gsap', 'gsap/ScrollTrigger']).
        Tier 3+
      framework: string (react|vue|svelte). Tier 4 only
      source_code: string (JSX/Vue SFC/Svelte source). Required when framework is
        set. Tier 4 only
      bundle_url: string (read-only — R2 URL of built bundle). Tier 4 only
      build_status: string (none|building|success|failed, read-only). Tier 4 only
      build_error: string (read-only — error message if build failed). Tier 4 only
      props_schema: object (JSON Schema defining accepted props)
      default_props: object (default prop values)
      thumbnail_url: string (preview image URL)
      tags: array of strings (for discovery)
      is_global: bool (available to all clients, default false)
    js_rules:
      scope: JS runs inside shadow root — 'root' param is the shadowRoot element
      props: '''props'' param contains the merged props object (page block props +
        defaults)'
      example: root.querySelector('.counter').textContent = props.start_value; root.querySelector('button').addEventListener('click',
        () => { /* ... */ })
      restrictions: No access to parent DOM. No Tailwind. Use root.querySelector()
        instead of document.querySelector().
    cdn_allowlist:
      description: Admin-managed list of approved CDN libraries for Tier 3 dependencies
      available_keys: gsap, gsap/ScrollTrigger, gsap/Flip, animejs, alpinejs, chartjs,
        lottie, swiper, countup, three
      discovery: GET /content/cdn-allowlist (public, no auth)
      admin_crud: POST/PATCH/DELETE /dashboard/content/cdn-allowlist (requires auth)
      validation: Unknown or disabled keys are rejected on component create/update
    framework_builds:
      description: Tier 4 components are built server-side with esbuild on publish
      workflow: 'create with framework+source_code → publish (returns 202) → poll
        build-status → success: bundle_url populated → component renders as <spideriq-app-{slug}>'
      supported_frameworks: react, vue, svelte
      source_format:
        react: JSX with export default function Name(props) { ... }
        vue: Vue SFC (<template>, <script setup>, <style scoped>)
        svelte: Svelte component (<script>, HTML, <style>)
    status_flow: draft → published → archived
    usage_in_page_blocks:
      description: Reference a component in a page block by slug
      example_block:
        type: component
        component_slug: hero-gradient-v1
        component_version: 1.0.0
        props:
          headline: Ship Faster
          cta_url: /signup
    api:
      list_published: GET /content/components
      get_published: GET /content/components/{slug}?version=
      cdn_allowlist_public: GET /content/cdn-allowlist
      list: GET /dashboard/content/components
      create: POST /dashboard/content/components
      get: GET /dashboard/content/components/{id}
      get_by_slug: GET /dashboard/content/components/by-slug/{slug}?version=
      update: PATCH /dashboard/content/components/{id}
      delete: DELETE /dashboard/content/components/{id}
      publish: POST /dashboard/content/components/{id}/publish (202 for Tier 4)
      archive: POST /dashboard/content/components/{id}/archive
      build_status: GET /dashboard/content/components/{id}/build-status (Tier 4)
      rebuild: POST /dashboard/content/components/{id}/rebuild (Tier 4, returns 202)
      versions: GET /dashboard/content/components/{slug}/versions
    docs: https://docs.spideriq.ai/site-builder/component-builder
block_types:
  _description: Blocks compose pages. Each block has {id, type, data:{...}}. Default
    theme reads SPECIFIC `data.*` keys per type — unrecognized keys store but render
    as empty markup. `_aliases` lists common mistakes + their canonical replacement.
  hero:
    fields:
      headline: string
      subheadline: string
      cta_primary: '{label: string, url: string}'
      cta_secondary: '{label: string, url: string}'
      background_image_url: string
      style: centered | left | split
    _aliases:
      title: headline
      subtitle: subheadline
      tagline: subheadline
      cta_text: 'cta_primary (object: {label, url})'
      cta_url: 'cta_primary (object: {label, url})'
      cta_label: 'cta_primary (object: {label, url})'
      cta_link: 'cta_primary (object: {label, url})'
      image_url: background_image_url
      background: background_image_url
  features_grid:
    fields:
      headline: string
      columns: int (2-4, default 3)
      features: '[{icon: string, title: string, description: string}]'
    _aliases:
      title: headline
      subtitle: (not supported — embed in features[].description)
      items: features
  cta_section:
    fields:
      headline: string
      description: string
      cta_primary: '{label: string, url: string}'
    _aliases:
      title: headline
      subtitle: description
      body: description
      cta_text: 'cta_primary (object: {label, url})'
      cta_url: 'cta_primary (object: {label, url})'
      cta_label: 'cta_primary (object: {label, url})'
      cta_link: 'cta_primary (object: {label, url})'
  faq:
    fields:
      headline: string
      items: '[{question: string, answer: string}]'
    _aliases:
      title: headline
      questions: items
      qa: items
  rich_text:
    fields:
      html: string (raw HTML — preferred for static content + Markdown-converted output)
      content: 'object (Tiptap JSON doc: {type:''doc'', content:[...]}) — NOT a string'
    _aliases:
      text: html (raw HTML string) or content (Tiptap JSON document)
      body: html
      markdown: html (render markdown server-side first, then pass HTML)
    _anti_patterns:
      '{type:''rich_text'', data:{content:''<string>''}}': REJECTED 422 since 2026-05-22
        — use data.html for raw HTML strings
      '{type:''rich_text'', data:{text:''...''}}': REJECTED 422 — use data.html or
        data.content
  stats_bar:
    fields:
      stats: '[{value: string, label: string}]'
    _aliases:
      title: (not supported — embed as a separate rich_text block above)
      items: stats
  testimonials:
    fields:
      headline: string
      testimonials: '[{quote: string, name: string, role: string, company: string}]'
    _aliases:
      title: headline
      items: testimonials
      quotes: testimonials
  pricing_table:
    fields:
      headline: string
      plans: '[{name, description, price, period, features: [string], cta: {label,
        url}, featured: bool}]'
    _aliases:
      title: headline
      items: plans
      tiers: plans
  image:
    fields:
      src: string (URL)
      alt: string
      caption: string
    _aliases:
      url: src
      image_url: src
      image: src
  video_embed:
    fields:
      provider: youtube | vimeo
      video_id: string
      url: string (fallback)
      caption: string
    _aliases:
      src: url
      video_url: url
      id: video_id
      youtube_id: video_id
  code_example:
    fields:
      title: string
      code: string
      description: string
  logo_cloud:
    fields:
      headline: string
      logos: '[{src: string, alt: string, url: string}]'
    _aliases:
      title: headline
      items: logos
  comparison_table:
    fields:
      headline: string
      subheadline: string
      eyebrow: string
      headers: '[string] (column labels — first may be empty for row-label column)'
      columns: '[string] (alias for headers — accepted as fallback)'
      rows: '[{label?: string, cells: [string]}]'
      style: default | striped
      footnote: string
    _aliases:
      title: headline
      subtitle: subheadline
      items: rows
    _notes: Renderer shipped 2026-05-22. Earlier versions had the type in the BlockType
      enum but no snippet — every comparison_table block rendered blank.
  spacer:
    fields:
      height: int (pixels, default 48)
liquid_templates:
  _description: Sites are rendered by Liquid templates in Cloudflare Workers. Templates
    fetch content from the API at request time.
  theme_structure:
    layout/theme.liquid: Base HTML shell (head, body, header/footer)
    templates/index.liquid: Homepage
    templates/page.liquid: Generic CMS page (renders blocks)
    templates/blog.liquid: Blog listing
    templates/blog-post.liquid: Single blog post
    templates/docs.liquid: Documentation landing
    templates/doc.liquid: Single doc page (with sidebar)
    templates/404.liquid: Not found page
    sections/header.liquid: Site header with navigation
    sections/footer.liquid: Site footer
    snippets/head.liquid: Meta tags, OG, analytics, theme CSS
    snippets/block-renderer.liquid: Dispatches CMS blocks to section templates
    snippets/post-card.liquid: Blog post card for listings
    assets/theme.css: Base CSS overrides
  template_api:
    list: GET /dashboard/templates
    get: GET /dashboard/templates/{path}
    create_or_update: PUT /dashboard/templates/{path}
    delete: DELETE /dashboard/templates/{path} (reverts to theme default)
    apply_theme: POST /dashboard/templates/apply-theme
    list_themes: GET /dashboard/templates/themes
    preview: POST /dashboard/templates/preview
liquid_filters:
  _description: Custom filters available in all templates via {{ value | filter_name
    }}
  tiptap_html:
    usage: '{{ post.body | tiptap_html }}'
    description: Converts Tiptap JSON document to HTML
  date_relative:
    usage: '{{ post.published_at | date_relative }}'
    description: Relative time (e.g. '2d ago', '3mo ago')
  date_iso:
    usage: '{{ post.published_at | date_iso }}'
    description: ISO 8601 format
  reading_time:
    usage: '{{ post.body | tiptap_html | reading_time }}'
    description: Estimated reading time in minutes (200 wpm)
  truncate_words:
    usage: '{{ text | truncate_words: 50 }}'
    description: Truncate to N words with '...'
  slugify:
    usage: '{{ title | slugify }}'
    description: Convert to URL-safe slug
  strip_html:
    usage: '{{ html | strip_html }}'
    description: Remove all HTML tags
  money:
    usage: '{{ price | money }} or {{ price | money: ''EUR'' }}'
    description: Format as currency
  img_url:
    usage: '{{ image_url | img_url: ''400x300'' }}'
    description: Cloudflare image resizing URL
  md:
    usage: '{{ text | md }}'
    description: Simple Markdown to HTML (bold, italic, links, paragraphs)
  hex_to_rgb:
    usage: '{{ primary_color | hex_to_rgb }}'
    description: Convert hex color to 'R,G,B' string
  json_parse:
    usage: '{{ json_string | json_parse }}'
    description: Parse JSON string to object
  json_stringify:
    usage: '{{ object | json_stringify }}'
    description: Serialize object to JSON string
liquid_tags:
  section:
    usage: '{% section ''hero'' %}'
    description: Render a section template from sections/ directory
  schema:
    usage: '{% schema %}{ ... JSON ... }{% endschema %}'
    description: Section settings declaration (metadata only, not rendered)
  style:
    usage: '{% style %}:root { --primary: {{ primary_color }}; }{% endstyle %}'
    description: Output scoped <style> tag with Liquid variable resolution
  component:
    usage: '{% component ''hero-gradient-v1'' %}'
    description: Render a registered component with Shadow DOM isolation. Auto-injects
      theme CSS variables into :host. Props from the page block are passed to the
      component template.
template_context:
  _description: Variables available in every template render.
  always_available:
    settings: object (all content_settings fields)
    site_name: string
    site_tagline: string
    primary_color: string (hex)
    logo_dark_url: string
    logo_light_url: string
    favicon_url: string
    social_links: 'object ({platform: url})'
    google_analytics_id: string
    nav.header: array of NavItem
    nav.footer: array of NavItem
    request.url: string (full URL)
    request.path: string (pathname)
    request.hostname: string
    request.query: 'object ({key: value})'
    theme: string (current theme name)
  per_template:
    templates/index.liquid:
      page: 'PageResponse (slug: ''home'')'
    templates/page.liquid:
      page: PageResponse
    templates/blog.liquid:
      posts: array of PostResponse
      total: int
      current_tag: string or null
    templates/blog-post.liquid:
      post: PostResponse
    templates/docs.liquid:
      docs_tree: array of DocTreeItem
    templates/doc.liquid:
      doc: DocResponse
      docs_tree: array of DocTreeItem
data_sources:
  _description: Connect SpiderIQ job results to templates. Data is fetched at request
    time and injected as template variables.
  supported_types:
  - spiderMaps (Google Maps business data)
  - spiderSite (website scraping results)
  - spiderVerify (email verification results)
  - spiderCompanyData (company intelligence via Perplexity)
  - 'lead-search (full pipeline: maps + site + verify)'
  configuration:
    description: Add via PATCH /dashboard/templates/config with data_sources array
    example:
      name: local_businesses
      type: spiderMaps
      job_id: abc-123
      variable_name: businesses
      refresh_interval: 3600
  template_usage: '{% for biz in businesses %}<h2>{{ biz.name }}</h2>{% endfor %}'
live_collections:
  _description: Live CMS collections, fetched from the API at request time and available
    to EVERY page template AND component — so you can build any data-driven widget
    anywhere (recent posts on the homepage, a changelog feed in a footer, a filtered
    card). No hardcoding, never stale.
  available_everywhere:
    posts: 'Published blog posts. Fields: slug, title, excerpt, cover_image_url, published_at,
      author_id, author, tags, reading_time, view_count, is_featured.'
    authors: 'Active authors. Fields: slug, full_name, avatar_url, bio, role, agent_type.'
    categories: 'Post categories (hierarchical). Fields: slug, name, description,
      parent_id, children.'
    tags: 'Post tags. Fields: slug, name, post_count.'
    changelog: 'Published changelog entries. Fields: version, title, body, published_at.'
    lead: On /lp/ dynamic-landing pages only — the resolved IDAP business record (null
      elsewhere). See dynamic_landing_pages.
  usage_in_any_component_or_template:
    iterate: '{% for post in posts %}<a href=''/blog/{{ post.slug }}''>{{ post.title
      }}</a>{% endfor %}'
    filter: '{% assign featured = posts | where: ''is_featured'', true | limit: 3
      %}'
    chain: '{{ authors | where: ''role'', ''editor'' | map: ''full_name'' | join:
      '', '' }}'
    note: Each collection defaults to [] when empty, so {% for %} is always a safe
      no-op. A collection is fetched only if a component on the page references it
      by name (cost gate).
  query_endpoint:
    description: Fetch filtered/sorted/paginated rows from any registered source —
      the same door a kind='dynamic' component binds to. Also the MCP tool list_data_source_items.
    url: GET /api/v1/content/data-sources/{source_id}/items?<filter>=<value>&sort=field[:asc|:desc]&limit=50&offset=0&fields=slug,title
    sources_v1:
    - posts
    - authors
    - categories
    - tags
    - changelog
    sources_phase_2:
    - idap.countries
    - idap.cities
    - idap.streets
    - idap.businesses (→ 501 today)
    singletons: idap.lead → 501 today (the idap.* Phase-2 guard runs before the singleton
      check), NOT 422. It reaches /lp/ templates as `lead`, not via /items. 422 is
      reserved for a future non-idap singleton.
    discover: GET /api/v1/content/data-sources lists every source + its filterable/sortable
      fields.
    returns: '{ items: [...], total, source_id }'
  build_a_dynamic_component:
    when: You want a reusable component that renders a live collection with server-side
      filtering (e.g. 'latest 3 posts in category news').
    steps:
    - '1. Discover the source + its fields: list_data_sources (MCP) or GET /content/data-sources.'
    - '2. (optional) Preview rows: list_data_source_items source_id=posts filter={tag:news}
      sort=-published_at limit=3.'
    - '3. Create as kind=''dynamic'' — REQUIRES block_type + js_runtime + non-empty
      sources (else backend 422 via chk_components_kind): content_create_component
      slug=... kind=dynamic block_type=list js_runtime=none sources=[{source_id:''posts'',
      default_filter:{tag:''news''}, default_sort:''-published_at'', default_limit:3}]
      html_template=''{% for item in items %}<a href="/blog/{{ item.slug }}">{{ item.title
      }}</a>{% endfor %}''.'
    - '4. Insert on a page: page_insert_section page_id=... component_slug=... — the
      renderer fetches the bound source server-side and exposes the rows as `items`
      (data_source / data_total also set).'
    - '5. Deploy: content_deploy_site.'
    items_vs_globals: Inside a dynamic component the bound+filtered rows arrive as
      `items`. The global `{{ posts }}`/`{{ authors }}`/… collections are ALSO available
      regardless — use those for a simple unfiltered list, `items` for the component's
      declared binding.
    pitfalls:
    - kind='dynamic' WITHOUT block_type+js_runtime+sources → 422 (chk_components_kind).
    - Binding a singleton source (idap.lead) to a list → 422; use it on a /lp/ page
      as `lead` instead.
    - Filters run server-side (the source binding or the /items query) — a client-side
      Liquid {% if %} does NOT re-query; it only filters rows already fetched.
    - idap.* collection lists return 501 today (Phase 2). The IDAP lead for /lp/ pages
      already works.
    see:
    - data_sources
    - components
    - build_a_page_with_blocks
custom_collections:
  _description: Define your OWN content types (a 'collection' = schema + records)
    and fill them entirely from an agent — no human field-builder. Homed in the content
    DB, PROJECT-scoped. The agent authors the field schema AND the records, then wires
    the collection into a live site as a data source — the differentiator vs a classic
    CMS where a human must define every field and type every row.
  when: You need structured, repeatable content the built-in types don't cover — case
    studies, team members, FAQs, products, testimonials, a portfolio. Reach for a
    page/post instead for one-off prose.
  model:
    collection: 'A definition: slug, label, schema_json (the field spec records validate
      against), route_base (optional detail-page base), is_public (expose via the
      /items door).'
    record: 'A row in a collection: slug, data (field values validated against schema_json),
      seo_title, seo_description, og_image_url, sort, publish_at, status (draft|published|archived).'
    scope: Project-scoped — bind a project first (`spideriq use <project_id>`, an
      `-w/--workspace`, or an X-Project-Id header) or the API 400s asking for a project.
    schema_json_shape: 'schema_json = {fields: [ {id, type, label?, options?}, ...
      ]}. `fields` is a LIST of field objects, NOT a dict keyed by name; each field
      REQUIRES an `id`. (A dict 422s ''fields Input should be a valid list''; a list
      item without id 422s ''fields.0.id Field required''.)'
    field_types: 'Valid `type` values (exactly): text · number · bool · select · date
      · richtext · media · relationship · blocks. There is NO `string` or `image`
      — use `text` / `media`.'
    richtext_caveat: A `richtext` field normalizes to a Tiptap doc at write. Do NOT
      store raw HTML in it — a raw HTML string is stored as ESCAPED text and renders
      as literal `<div>…` markup. Author it as Markdown/Tiptap, or use a flat `text`
      field for plain strings.
    blocks_caveat: 'A `blocks` field holds PAGE-BLOCKS: each item must be a page block
      whose `type` is one of the 14 page block-types (hero, features_grid, rich_text,
      cta_section, …, component) — arbitrary objects 422.'
  mcp_tools:
    collection_list: List the project's collections.
    collection_get: Get one collection by slug.
    collection_create: Define a collection (slug + label + schema_json). Non-destructive.
      Enforces max_collections.
    collection_update: Edit a collection's label/schema_json/route_base/is_public.
      Non-destructive.
    collection_delete: 'Delete a collection — DESTRUCTIVE (cascades to records). Gated:
      previews unless confirm_token is set.'
    collection_record_list: List a collection's records (drafts + published). Each
      record carries EVERY field of its schema — a wide collection costs tens of thousands
      of characters per page — so pass fields=['a','b'] to narrow record.data (envelope
      always returns; unknown ids are ignored, not rejected) and limit to bound the
      page.
    collection_record_get: Get one record by its record slug.
    collection_record_create: Create ONE draft record. Non-destructive. Enforces max_records.
    collection_record_update: Update a record by ID. A `status` change is GATED (dry_run→confirm_token);
      draft-field edits apply immediately.
    collection_record_delete: Delete one record — DESTRUCTIVE. Gated.
    collection_record_bulk: Create 1–100 records ATOMICALLY (one tx; any validation
      error rejects the whole batch). Enforces max_records for the batch.
  cli:
    collections: spideriq content collections list | get <slug> | create | update
      <slug> | delete <slug>
    records: spideriq content collections records list <slug> | get <slug> <record_slug>
      | create <slug> | update <slug> <record_id> | delete <slug> <record_id> | bulk
      <slug>
  endpoints:
    base: /api/v1/dashboard/projects/{project_id}/content/collections (also the legacy
      /api/v1/dashboard/content/collections when a default project resolves)
    collection_crud: POST /collections · GET /collections · GET/PATCH/DELETE /collections/{slug}
    record_crud: POST/GET /collections/{slug}/records · POST /collections/{slug}/records/bulk
      · GET /collections/{slug}/records/{record_slug} · PATCH/DELETE /collections/{slug}/records/{record_id}
    read_side: 'Public rows read through the SAME /items door as built-in collections
      once is_public=true: GET /api/v1/content/data-sources/{collection_slug}/items
      — so a kind=''dynamic'' component binds to a custom collection exactly like
      it binds to `posts`. This door resolves the tenant from the X-Content-Domain
      header (the owning site''s domain), NOT X-Project-Id — a request without it
      resolves the default tenant and 404s ''Data source not found'' even though the
      collection exists.'
  gating: delete-collection, delete-record, and a record status transition (publish/archive/unpublish)
    return a preview + confirm_token on the first call (dry_run defaults true for
    deletes); call again with confirm_token to commit. Creating/editing a DRAFT is
    non-destructive — no gate.
  quotas: Collection + record creates enforce the plan caps max_collections / max_records
    (403 with rule_id=max_collections_exceeded / max_records_exceeded). A bulk create
    is checked against the full batch size.
  unknown_fields: Records reject-with-warning on fields not in schema_json (fail-loud
    for agents) — a 422 carrying {errors, warnings}.
  build_a_collection_backed_site:
    when: You want, e.g., a Case Studies section that renders live rows an agent authored.
    steps:
    - '1. Define: collection_create slug=case-studies label=''Case Studies'' schema_json={fields:[{id:title,
      type:text, label:Title}, {id:client, type:text, label:Client}, {id:summary,
      type:text, label:Summary}]}. `fields` is a LIST of {id, type, label} (a dict
      422s ''Input should be a valid list''; a list item missing id 422s ''fields.0.id
      Field required'').'
    - '2. Fill (bulk): collection_record_bulk collection=case-studies records=[{slug:''acme'',
      data:{title:''Acme'', client:''Acme Co'', summary:''...''}}, ...] (1–100 in
      one tx).'
    - '3. Publish rows: collection_record_update collection=case-studies record_id=<id>
      status=published dry_run=true → then confirm_token=<cft>.'
    - '4. Expose: collection_update slug=case-studies is_public=true (opens the /items
      door).'
    - '5. Render: create a kind=''dynamic'' component whose source_id is the collection
      slug (binds like `posts`), then page_insert_section — the renderer fetches the
      rows server-side as `items`.'
    - '6. Deploy: content_deploy_site.'
    see:
    - live_collections
    - components
    - build_a_page_with_blocks
press_newsroom:
  _description: 'A full newsroom an agent can run end-to-end: press releases plus
    the three things a real newsroom needs around them — a press-contact roster, reusable
    boilerplates, and downloadable media kits. Every verb is available to an agent
    over MCP and CLI, which is the point: no other newsroom product exposes a write-capable
    API.'
  when: Announcements, launches, funding news, statements, media alerts. Reach for
    a post/blog entry instead for ongoing editorial content, and the changelog for
    version-stamped release notes.
  model:
    release: 'The announcement itself. Slug + headline + body, with an optional dateline,
      hero image and legal tail. Statuses: draft | scheduled | embargoed | published
      | archived.'
    contact: A press contact (name, title, email, phone, region, beat, timezone).
      Attach several to a release via contact_ids — this is the 'who to call' block
      journalists read.
    boilerplate: The reusable 'About <company>' paragraph appended to a release. Hold
      one per language and mark one is_default.
    media_kit: A downloadable asset bundle. STORY-scoped, not one-per-company — a
      company can hold many (launch kit, brand kit, exec headshots). Attach ALREADY-UPLOADED
      media rows; the kit tools do not upload.
    scope: Project-scoped like other content — bind a project (`spideriq use <id>`,
      `-w/--workspace`, or an X-Project-Id header). A release can be moved between
      projects with press_reassign_project, which CARRIES its contacts/kit/boilerplate
      rather than stranding them.
  mcp_tools:
    _naming_note: 'Like changelog_*, these break the content_* prefix: they are named
      press_list / press_create / press_publish (NOT content_create_press_release).
      Searching for ''content_*_press'' returns nothing — search ''press'' instead.'
    press_list: List releases newest-first, DRAFTS INCLUDED (this is the author door).
      Keyset-paginated — pass the prior response's next_cursor, never a page number.
    press_get: Get one release in full, including its resolved contacts.
    press_create: Create a release. Lands as a DRAFT — nothing is public until publish/schedule.
    press_update: PATCH a release; only the fields you send change. Editing a published
      release is live immediately.
    press_delete: Delete a release — DESTRUCTIVE. Prefer press_unpublish to just take
      it off the newsroom.
    press_publish: 'Go live now: appears on the newsroom + feeds and notifies subscribed
      journalists. Reversible via press_unpublish.'
    press_unpublish: Return a published release to draft, keeping its content.
    press_schedule: Mark a release to go live at a FUTURE ISO-8601 time (status →
      scheduled). A past time 400s — use press_publish for an immediate go-live.
    press_unschedule: Cancel a scheduled publish — back to draft, future date cleared.
    press_reassign_project: Move a release to another project in the same workspace,
      carrying its relations.
    press_contact_*: list / get / create / update / delete the press-contact roster.
    press_boilerplate_*: list / get / create / update / delete boilerplates (list
      takes an optional language filter).
    press_kit_*: list / get / create / update / delete kits, plus attach_asset / detach_asset
      for their files.
  recipe:
  - '1. press_contact_create(name=''...'', title=''Head of Comms'', email=''press@…'')  #
    who journalists call'
  - 2. press_boilerplate_create(label='About Acme — short', body='...', is_default=true)
  - '3. press_kit_create(slug=''launch-kit'', name=''Launch kit'')  # then press_kit_attach_asset(kit_id,
    media_id) per file'
  - '4. press_create(slug=''we-raised-a-series-a'', title=''...'', body={''markdown'':
    ''...''}, dateline_city=''BERLIN'', boilerplate_id=…, media_kit_id=…, contact_ids=[…])  #
    DRAFT'
  - 5. press_publish(release_id)  — or press_schedule(release_id, '2026-08-01T09:00:00Z')
    to stage it
  gotchas:
    body_format: 'body is a Tiptap JSON document, but you may send {''markdown'':
      ''...''} or {''html'': ''...''} and it is normalized server-side — same contract
      as the changelog.'
    drafts_are_included: press_list is the AUTHOR door and returns drafts. The public
      newsroom read (/content/press) never does — an empty public feed with rows in
      press_list means nothing is published yet, not that data is missing.
    cursor_not_offset: Pagination is keyset. `cursor` is an opaque token from the
      prior response's next_cursor; it is NOT a page number and does not accept one.
    kits_do_not_upload: press_kit_attach_asset takes an EXISTING media id. Upload
      the file first (the media tools), then attach. File size is denormalized from
      the media row automatically.
    no_embargo_yet: The `embargoed` status and embargo_until exist on the model, but
      there is NO embargo endpoint and nothing mints an embargo token yet — that ships
      in a later slice. Do not build a journalist-preview flow on it today.
    scheduled_is_intent_only_today: press_schedule records the intent and flips the
      status to `scheduled`, but the sweeper that auto-publishes at that moment ships
      in a later slice. Until it lands, call press_publish when you actually want
      a release live.
    antigravity: The press tools ship in the kitchen-sink @spideriq/mcp only — they
      are NOT in @spideriq/mcp-publish, so an Antigravity-style 128-tool content slice
      will not see them. Use @spideriq/mcp for newsroom work.
  cli: spideriq content press list|get|create|update|delete|publish|unpublish|schedule|unschedule|reassign,
    plus `press contacts|boilerplates|kits <verb>`.
  see:
  - content_types
  - changelog
  - deploy
deploy:
  description: Deploy your site to Cloudflare edge (300+ locations worldwide)
  api:
    deploy: POST /dashboard/content/deploy
    status: GET /dashboard/content/deploy/status
    history: GET /dashboard/content/deploy/history
  pipeline:
  - 1. Templates uploaded to per-client KV namespace
  - 2. _config.json written (theme settings, data sources)
  - 3. Liquid renderer Worker deployed to Cloudflare
  - 4. Domain mappings updated
  - 5. Site live (~2-5 seconds total)
dynamic_landing_pages:
  description: Personalized pages that use CRM lead data. Each visitor sees content
    tailored to their business.
  url_patterns:
    lead_only: /lp/{page_slug}/{identifier}
    with_salesperson: /lp/{page_slug}/{salesperson_slug}/{identifier}
    example: /lp/wifi-proposal/ajay/0x47e66fdad6f1cc73:0x341211b3fccd79e1
  identifier_types:
    place_id: Google Maps Place ID (most common)
    domain: Business domain name
    email: Contact email address
  template_variables:
    lead:
      description: Full business record from IDAP. Present when the URL identifier
        resolves; NULL when it does not (bad/unknown id, empty IDAP, expired link).
      fields: name, address, city, country_code, rating, reviews_count, phone_e164,
        domain, website, categories, description
      related: lead.related.emails, lead.related.phones, lead.related.domains, lead.related.contacts
      usage: '{{ lead.name }}, {{ lead.city }}, {{ lead.rating }}'
  null_lead_contract:
    rule: When the identifier does NOT resolve, `lead` is null. This NEVER 500s —
      the renderer runs strictVariables=off, so `{{ lead.name }}` renders empty rather
      than throwing, and the flat merge tags ({{company_name}} etc.) degrade to ''.
    if_you_write_a_custom_template: Guard every block that depends on real lead data
      with `{% if lead %}…{% endif %}`. Render authored page blocks + hero OUTSIDE
      that guard so the page still shows up with no lead.
    fallback_pattern: '{% assign biz = lead.name | default: page.custom_fields.demo_business
      | default: ''Your Business'' %} — the shipped default dynamic-landing template
      uses exactly this so a no-lead visit still renders a usable page.'
    what_500s_really_mean: A 500 on /lp/ is a bug in a CUSTOM tenant template dereferencing
      nested lead.* without an `{% if lead %}` guard — NOT the platform. Missing lead
      data alone cannot 500.
    salesperson:
      description: Salesperson profile from template config, matched by URL slug
      fields: name, title, location, bio, photo_url, calendar_url
      usage: '{{ salesperson.name }}, {{ salesperson.calendar_url }}'
      config: Set in content_template_configs.salespersons JSONB
  template_personalization:
    replace_filter: '{{ page.custom_fields.headline | replace: ''{business}'', lead.name
      | replace: ''{city}'', lead.city }}'
    conditional: '{% if lead.rating > 4 %}Top Rated!{% endif %}'
    related_data: '{% for email in lead.related.emails %}{{ email.address }}{% endfor
      %}'
  setup_steps:
  - '1. POST /dashboard/content/pages — create page with template: ''dynamic_landing'',
    use {business}/{city} placeholders in custom_fields'
  - 2. PATCH /dashboard/templates/config — add salesperson profiles to 'salespersons'
    field
  - 3. POST /dashboard/content/pages/{id}/publish — publish the page
  - 4. POST /dashboard/content/deploy — deploy to Cloudflare edge
  - '5. Share URL: https://yoursite.com/lp/{page_slug}/{salesperson}/{google_place_id}'
  api:
    resolve_lead: GET /content/leads/resolve?place_id={id}&include=emails,phones
    description: Public endpoint (domain-based auth) used by the Liquid renderer at
      render time
quickstart:
  description: Steps to build a complete site from scratch. Follow ALL steps — deploy
    will reject if blocking requirements are missing.
  prerequisite: Run `spideriq use <project>` once in the repo root — writes `spideriq.json`
    so every dashboard URL auto-scopes to /dashboard/projects/{project_id}/...
  readiness_check: GET /dashboard/projects/{pid}/content/deploy/readiness — returns
    a checklist of what's configured and what's missing. Call this BEFORE deploying.
  steps:
  - 0. `spideriq use <project>` (once) — binds this directory; every URL below auto-rewrites
    to /projects/{pid}/...
  - 1. GET /content/help — read this reference
  - '2. PATCH /dashboard/projects/{pid}/content/settings — REQUIRED: set site_name,
    primary_color, logo (gated: call first with ?dry_run=true, then ?confirm_token=...)'
  - 3. PUT /dashboard/projects/{pid}/content/navigation/header — set up menu items
    (recommended)
  - '4. POST /dashboard/projects/{pid}/content/pages — create homepage (slug: ''home'')
    with blocks'
  - '5. POST /dashboard/projects/{pid}/content/pages/{id}/publish — REQUIRED: publish
    at least 1 page (gated: dry_run → confirm_token)'
  - 6. POST /dashboard/projects/{pid}/content/posts — create blog posts (optional)
  - '7. POST /dashboard/projects/{pid}/templates/apply-theme — REQUIRED: apply ''default''
    theme (gated: dry_run → confirm_token)'
  - 8. GET /dashboard/projects/{pid}/content/deploy/readiness — verify all blocking
    checks pass
  - 9. POST /dashboard/projects/{pid}/content/deploy/preview — get preview URL + confirm_token
  - 10. POST /dashboard/projects/{pid}/content/deploy/production?confirm_token=cft_...
    — deploy to Cloudflare edge
  blocking_requirements:
  - content_settings with site_name must exist (step 2)
  - At least 1 verified domain (add via POST /dashboard/content/domains)
  - At least 1 template applied (step 7)
  - At least 1 published page (step 5)
  warnings:
  - Set a primary domain (POST /dashboard/content/domains/{domain}/primary) or deploy
    status won't show your URL
  - Set up header navigation or site will have no menu
  - Component slugs must be unique per version — creating a duplicate returns 400
booking:
  _description: Two-JSON booking engine. flow.json = ordered steps; schema.json =
    per-step fields. Cal.com syncs calendars, IDAP stores flows/services/bookings,
    SpiderPublish renders.
  docs: https://docs.spideriq.ai/booking
  step_types:
  - select
  - calendar
  - form
  - confirm
  field_types:
  - text
  - email
  - phone
  - tel
  - textarea
  - select
  - checkbox
  - consent
  - number
  - date
  - time
  workflow:
  - booking_template_list → clone → service_create x N → flow_preview → flow_publish
    → embed in page → deploy
  tools:
    booking_template_list: GET /booking/templates/global?category= — list seed templates
    booking_template_get: GET /booking/templates/{id} — full flow+schema
    booking_template_clone: POST /booking/templates/clone — destructive
    booking_flow_create: POST /booking/flows — destructive
    booking_flow_get: GET /booking/flows/{id}
    booking_flow_update: PATCH /booking/flows/{id} — destructive
    booking_flow_publish: POST /booking/flows/{id}/publish — destructive (draft→active)
    booking_flow_preview: GET /booking/flows/{id}/preview — read-only URL
    service_create: POST /booking/services — destructive (price_cents)
    service_update: PATCH /booking/services/{id} — destructive
    service_delete: DELETE /booking/services/{id} — destructive (soft)
    booking_list: GET /booking/bookings — cursor paginated
    booking_get: GET /booking/bookings/{id}
    booking_reschedule: POST /booking/bookings/{id}/reschedule — destructive
    booking_cancel: POST /booking/bookings/{id}/cancel — destructive
  templates:
    nail-salon-default: category=nail_salon · service→staff→slot→contact→summary ·
      cal.com
    restaurant-default: category=restaurant · party→slot→contact→summary · idap://availability
    sales-call-default: category=sales_call · qualify→slot→summary · cal.com
  destructive_convention: Every mutating tool defaults to dry_run=true and returns
    a confirm_token (TTL ~300s). Echo it back to mutate. Same pattern as the content
    tools.
  block_usage: 'Page block: { "type": "booking", "flow_id": "bf_…" }. Dynamic-landing:
    {% block type="booking" flow_id="{{ business.booking_flow_id }}" %}.'
  customer_manage: GET|POST /booking/public/manage/{id}?token=<HMAC-jwt> (view/reschedule/cancel)
agent_skills:
  description: Curated skill library for building SpiderPublish sites, shipped in
    the public starter kit. Just what you need to deploy a client site, nothing more.
    Tier 3 impl.ts files use only Node 18+ stdlib (fetch, fs, path) — zero npm deps.
    Claude Code / Cursor / Antigravity can copy-paste and run them directly with `npx
    tsx impl.ts`. No extra runtime required.
  location: github.com/SpiderIQ/SpiderPublish/tree/main/designer-kit/skills
  clone_command: npx degit SpiderIQ/SpiderPublish/designer-kit my-site
  core_building_blocks:
    description: Already exposed via @spideriq/mcp-publish — these skill docs are
      the human/agent-readable reference.
    skills:
    - content-platform   — Pages, posts (authors/tags/categories), docs, nav, settings,
      components
    - templates-engine   — Liquid templates, themes, deploy to edge
    - upload-host-media  — Media upload to CDN
    - agentdocs          — Versioned docs projects
    note: Blog authoring lives inside content-platform (see its 'Blog authoring workflow'
      section) — the tools share the content_* namespace with pages.
  recipes:
    description: Multi-step workflows that compose MCP tools. Tier 1 YAML for reading,
      Tier 2 schema for tool sequences, Tier 3 impl.ts for direct execution.
    skills:
    - scroll-sequence    — Video → extract_frames → sys-scroll-sequence → deploy
    - preview-iteration  — Preview → browser-check → confirm_token → production
    - bulk-media-upload  — Local directory → multipart upload → URL map (kills pinggy
      hacks)
  when_to_use_mcp_vs_a_skill: 'MCP tools: single-step typed CRUD (create page, publish
    component, apply theme). Skills: multi-step workflows with branching logic, polling,
    filesystem I/O, or domain-specific sequencing.'
marketplace:
  description: Marketplace V2 — discover bg-videos, components, site-templates across
    4 universal axes (mood, palette, brand_fit, scene_type) and per-type agent_meta
    keys. All vocabularies are validated against Pydantic enums; values listed here
    are exhaustive and match the write-validation layer byte-for-byte.
  universal_axes:
    mood:
      kind: multi-value text[]
      applies_to:
      - bg_video
      - component
      - site_template
      values:
      - calm
      - energetic
      - bold
      - confident
      - dreamy
      - futuristic
      - urban
      - minimal
      - warm
      - sensory
      - editorial
      - professional
      - friendly
      - clear
      - technical
      - credible
      description: 'Tonal descriptor of the asset. Multi-value: an asset can be both
        ''calm'' and ''dreamy''. Filter is set-overlap (&&), so passing one mood includes
        assets that have that mood AND others.'
    palette:
      kind: multi-value text[]
      applies_to:
      - bg_video
      - component
      - site_template
      values_open: true
      common_examples:
      - monochrome
      - deep-blue
      - warm-orange
      - neutral-warm
      - neon-accent
      - nature-green
      - rich-brown
      - warm-earth
      - blue-night
      - cinematic
      - dark-mode-first
      - brand-led
      description: Color tokens or hex hints. Open vocabulary — tokens are catalog-curator
        authored. The set above lists the Phase-A seed tokens; new tokens may be introduced
        without a migration.
    brand_fit:
      kind: multi-value text[]
      applies_to:
      - bg_video
      - component
      - site_template
      values:
      - saas
      - agency
      - ecommerce
      - fintech
      - real-estate
      - hospitality
      - restaurant
      - wellness
      - healthcare
      - blog
      - publication
      - personal
      - tech
      - design
      - consulting
      - outdoor
      - lifestyle
      description: Industry verticals the asset is suitable for. Multi-value. Filter
        is set-overlap — pass 'fintech' to find every fintech-suitable asset.
    scene_type:
      kind: single-value text
      applies_to:
      - bg_video
      - component
      - site_template
      values:
      - hero-bold
      - feature-grid
      - pricing-tiers
      - social-proof
      - faq-accordion
      - conversion-cta
      - navigation-header
      - navigation-footer
      - data-collection-form
      - editorial-content
      - team-grid
      - city-aerial
      - nature-landscape
      - abstract-motion
      - food-prep
      - people-lifestyle
      - tech-hardware
      - marketing-site
      - docs-site
      - directory-site
      - portfolio-site
      description: Single-value scene/intent. Vocabularies overlap intentionally across
        asset types where the concept carries (e.g. 'hero-bold' for a component AND
        a site-template mood-board).
  agent_meta_keys:
    bg_video:
      pydantic_class: schemas.marketplace_agent_meta.BgVideoAgentMeta
      keys:
        pace:
        - slow
        - medium
        - fast
        time_of_day:
        - dawn
        - day
        - dusk
        - night
        weather:
        - clear
        - cloudy
        - rain
        - snow
        - fog
        - stormy
        aspect_ratio:
        - '16:9'
        - '9:16'
        - '1:1'
        - '4:3'
        - '21:9'
        has_people:
        - true
        - false
        has_audio:
        - true
        - false
        music_tempo_bpm: int 20..300
        transcript: string max 2000
      filter_syntax: agent_meta.<key>=<value> on the per-type endpoint or /marketplace/search
    component:
      pydantic_class: schemas.marketplace_agent_meta.ComponentAgentMeta
      keys:
        interaction_pattern:
        - static
        - click
        - hover
        - scroll
        - timer
        - form
        - drag
        trigger_kind:
        - page-load
        - scroll-into-view
        - click
        - hover
        - exit-intent
        - timer-fixed-date
        - timer-elapsed
        - form-submit
        - geo-match
        - none
        placement:
        - above-fold
        - below-fold
        - side-rail
        - modal
        - toast
        - footer
        - header
        - any
        conversion_strategy:
        - primary-cta
        - secondary-cta
        - trust
        - scarcity
        - social-proof
        - education
        - navigation
        - none
        motion_safety:
        - true
        - false
        accessibility_notes: string max 1000
      filter_syntax: agent_meta.<key>=<value> on /marketplace/components or /marketplace/search
    agent:
      pydantic_class: schemas.marketplace_agent_meta.AgentAssetMeta
      note: Used INSTEAD OF the component shape when marketplace_category='agent'.
        Same agent_meta column, dispatched by marketplace_category. An agent listing
        is a discovery card; the live agent runs as a kind='agent' funnel and discovery
        deep-links to OPVS hire/order (client_id + role).
      keys:
        role:
        - sdr
        - support
        - concierge
        - booking
        surface:
        - flow
        - inline
        - concierge
        form_factor:
        - section
        - widget
        - concierge
        - headless
        grounding_modes:
        - page
        - site
        - docs
        trigger_kind:
        - page-load
        - scroll-into-view
        - click
        - hover
        - exit-intent
        - timer-fixed-date
        - timer-elapsed
        - form-submit
        - geo-match
        - none
        required_scopes: list[string] max 32 (e.g. crm.read, calendar.write)
        opvs_catalog: 'object (OpvsCatalogRef) — set ONLY on rows synced from the
          OPVS public hiring catalog (task 2.1). Carries OPVS provenance + a display
          snapshot: profile_id (the hire handoff key + dedupe anchor), slug, role_title,
          persona_name, avatar_url, video_url, price_monthly/price_annual/price_label,
          billing_cycle, rating, hire_count, availability_status, category_slug, synced_at.
          Presence of opvs_catalog.profile_id marks a tile as a hireable OPVS employee
          (Hire CTA) vs a hand-authored listing.'
      filter_syntax: 'marketplace_category=agent, then narrow by the agent facets.
        PARAM CONVENTION DIFFERS BY SURFACE: the PUBLIC /content/marketplace/components
        (and /content/components) endpoint takes the DOTTED JSONB path — agent_meta.form_factor=widget,
        agent_meta.role=sdr, agent_meta.surface=inline. The DASHBOARD /dashboard/content/components
        endpoint takes the SCALAR aliases — agent_form_factor=widget, agent_role=sdr,
        agent_surface=inline. Using the scalar against the public endpoint (or vice-versa)
        is silently ignored — it returns all agents, not a narrowed set. (Cross-table:
        agent_meta.role= on /marketplace/search.)'
    site_template:
      pydantic_class: schemas.marketplace_agent_meta.SiteTemplateAgentMeta
      keys:
        style_aesthetic:
        - minimal
        - bold
        - editorial
        - playful
        - premium
        - technical
        - brutalist
        - soft
        conversion_strategy:
        - primary-cta
        - secondary-cta
        - trust
        - scarcity
        - social-proof
        - education
        - navigation
        - none
        page_count: int 1..200
        has_blog:
        - true
        - false
        has_pricing:
        - true
        - false
        has_directory:
        - true
        - false
        has_booking:
        - true
        - false
        component_set: list[string] max 100
      filter_syntax: agent_meta.<key>=<value> on /marketplace/site-templates or /marketplace/search
  component_classes:
    description: Every component row carries a kind ∈ {static, interactive, dynamic,
      extension}. The class drives editor tabs, validator rules, and which tasks.add_*_component
      recipe applies.
    values:
    - static
    - interactive
    - dynamic
    - extension
    dynamic_block_types:
    - list
    - item_details
    - form
    - calendar
    - kanban
    - chart
    - map
    - table
    js_runtimes:
    - vanilla
    - web-component
    - island
    - none
  endpoints:
    per_type_listing:
    - GET /content/marketplace/bg-videos?mood=&palette=&brand_fit=&scene_type=&agent_meta.pace=...
    - GET /content/marketplace/components?mood=&palette=&brand_fit=&scene_type=&agent_meta.placement=...
    - GET /content/marketplace/site-templates?mood=&palette=&brand_fit=&scene_type=&agent_meta.has_blog=...
    cross_table_search: GET /content/marketplace/search?asset_types=bg_video,component,site_template&mood=&palette=&brand_fit=&scene_type=&limit=20
    per_asset_detail:
    - GET /content/marketplace/bg-videos/{slug}
    - 'GET /content/marketplace/components/{slug}  # alias of /content/components/{slug}'
    - 'GET /content/marketplace/site-templates/{slug}  # alias of /content/site-templates/{slug}'
    data_sources: 'GET /content/data-sources  # registry of dynamic-block sources'
    format_support: Append ?format=yaml or ?format=md to any GET for token-efficient
      agent-friendly output.
  authentication:
    description: 'The ''authentication'' marketplace category (Login Initiative A)
      — designable sign-in bricks. Three global interactive components, each rendering
      ONE <spideriq-auth> custom element (CLOSED shadow DOM) mode-switched by the
      row. The auth backends are now LIVE: auth_target=dashboard signs into the SpiderIQ
      dashboard via the Initiative-B broker (including self-serve SIGNUP — mode=signup
      creates a brand-new free-tier dashboard account), and auth_target=site_members
      signs into the tenant''s own member store via Initiative C — a dropped brick
      is fully functional end to end (renders, themes, AND signs in or signs up).'
    marketplace_category: authentication
    components:
      spideriq/auth-login: mode=login — email/password + optional OAuth (google/github/magic_link),
        signup-link, forgot link, post-login redirect.
      spideriq/auth-forgot-password: mode=forgot — collect an email, request a reset
        link, neutral success copy (no account enumeration).
      spideriq/auth-reset-password: mode=reset — read the token from the URL, take
        a new password (configurable min length), submit.
      spideriq-auth mode=signup: 'mode=signup (brick v0.3.0+, auth_target=dashboard)
        — email/password self-serve signup. POSTs {api_base}/api/v1/auth/broker/signup,
        provisions a NEW free-tier dashboard account in its own isolated workspace
        (NOT the referring tenant''s), requires email verification before first login,
        mints NO session at signup. Props: signupEndpoint, login-link (the ''Already
        have an account? Sign in'' affordance). No enumeration on duplicate email;
        gateable via AUTH_BROKER_SIGNUP_GATED.'
    key_prop:
      auth_target:
        required: true
        values:
        - dashboard
        - site_members
        description: The hinge. 'dashboard' authenticates into the SpiderIQ dashboard
          via the Initiative-B broker (POST {api_base}/api/v1/auth/broker/login ->
          one-time code -> redirect to {api_base}/auth/handoff). 'site_members' authenticates
          into the tenant's OWN member store via Initiative C (POST {members_base}/api/auth/sign-in/email).
          The two identity worlds never share a user store or session.
    shared_props:
    - auth_target
    - api_base
    - members_base
    - methods
    - theme
    element: spideriq-auth
    cdn_key: spideriq-auth
    status_note: 'LIVE end to end: both auth_target=dashboard (Initiative B broker
      + host-only handoff) and auth_target=site_members (Initiative C same-host bridge)
      sign users in. Self-serve SIGNUP is LIVE for auth_target=dashboard (mode=signup,
      brick v0.3.0+) — a fresh free-tier account in its own workspace, email-verification-gated.
      OAuth providers on auth_target=dashboard ALWAYS redirect to the central origin
      for the OAuth dance — never initiate OAuth on the tenant domain.'
    see:
    - tasks.build_a_login_page
    - components
