spideriq_content_reference:
  version: 11.13.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
    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
  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
  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/martinshein/SpideriQ-ai/tree/main/SpiderPublish/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 a sandbox
      URL (pure, no DB writes)
    - 3. Open the sandbox URL in any browser and verify rendering
    - '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. 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/martinshein/SpideriQ-ai/tree/main/SpiderPublish/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/martinshein/SpideriQ-ai/tree/main/SpiderPublish/skills/recipes/bulk-media-upload
    see:
    - media
  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?)
    - '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
    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.
    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>
    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)
    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)
  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 a type and data object.
  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
  features_grid:
    fields:
      headline: string
      columns: int (2-4, default 3)
      features: '[{icon: string, title: string, description: string}]'
  cta_section:
    fields:
      headline: string
      description: string
      cta_primary: '{label: string, url: string}'
      style: default | banner
  faq:
    fields:
      headline: string
      items: '[{question: string, answer: string}]'
  rich_text:
    fields:
      content: object (Tiptap JSON)
      html: string (pre-rendered HTML, optional)
  stats_bar:
    fields:
      stats: '[{value: string, label: string}]'
  testimonials:
    fields:
      headline: string
      testimonials: '[{quote: string, name: string, role: string, avatar: string}]'
  pricing_table:
    fields:
      headline: string
      plans: '[{name, description, price, period, features: [string], cta: {label,
        url}, featured: bool}]'
  image:
    fields:
      src: string (URL)
      alt: string
      caption: string
  video_embed:
    fields:
      provider: youtube | vimeo
      video_id: string
      url: string (fallback)
      caption: string
  code_example:
    fields:
      title: string
      code: string
      description: string
  logo_cloud:
    fields:
      headline: string
      logos: '[{src: string, alt: string, url: string}]'
  comparison_table:
    fields:
      headline: string
      headers: '[string]'
      rows: '[[string]]'
  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 %}'
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, available when identifier resolves
      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 }}'
    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/martinshein/SpideriQ-ai/tree/main/SpiderPublish/skills
  clone_command: npx degit martinshein/SpideriQ-ai/SpiderPublish 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
    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.
