Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.propal.io/llms.txt

Use this file to discover all available pages before exploring further.

The Propal components DSL is a typed JSON grammar that produces valid TipTap editor content without exposing the raw editor JSON. Agents author sections by composing typed blocks; the server validates against Zod schemas, applies structural rules, and assembles the final document atomically.

Mental model

A proposal is a suite of sections. Each visual block of the document (intro, goals, pricing, testimonials…) is wrapped in a section. Anything outside a section uses page-level styling. Structural rules (enforced by Zod + a runtime validator with explicit error messages):
  • The root of any tree submitted via the DSL is a section or section[].
  • A section cannot contain another section — flat hierarchy at the root.
  • Inside a section, only blocks are allowed: paragraph, heading, columns, cards, mini_table, gallery, etc.
  • columns.cols must equal columns.length.
  • image / icon / video are placeholders — no src accepted.
  • embed is the only block that takes an external URL (with provider-host allowlist).
  • Card-grouped components (testimonial_cards, process_cards, …) always assemble to a columns block with one card per column. Proprietary card containers are never produced.

Schema discovery

Agents use two tools to learn the grammar at runtime:

list_propal_components

Returns the full catalog with descriptions, structural rules, limits, and a compact example per component.

get_propal_component_schema

Returns the detailed Zod-derived schema for a single component ({ component_type: 'section' | 'columns' | ... }).
The descriptions in those tools are the source of truth — this page mirrors them but the live tool always wins.

Layout

ComponentPropsNotes
sectiontheme?, section_style?, background_image?, badge?, badge_color?, title?, title_level?, description?, children: Block[]Root container. theme is legacy; prefer section_style + a proposal theme. See theming.
columnscols: '2'|'3'|'4', column_widths?, columns: Block[][]One sub-tree per column. columns.length must equal cols.
spacerheight?Vertical space.

Basic blocks

ComponentProps
paragraphtext, align?
headinglevel: 1|2|3, text, align?
bullet_listitems: string[]
divider(no props — renders horizontalRule)
badgetext, color, size, rounded
starscount, color
profilename, title (avatar is a placeholder)

Media

Placeholders only — no external URLs:
ComponentMaps to
imageimageUpload (componentUpload: 'block')
iconimageUpload (componentUpload: 'icon')
videovideoUpload
The user uploads the actual asset in the editor after the agent has built the structure. With URL:
{
  type: 'embed',
  provider: 'youtube' | 'loom' | 'vimeo' | 'figma' | 'drive' | 'generic',
  src: string, // HTTPS, validated against the provider's host allowlist
  height?: string,
}
Allowed hosts:
ProviderHosts
youtubeyoutube.com, youtu.be
loomloom.com
vimeovimeo.com, player.vimeo.com
figmafigma.com
drivedrive.google.com, docs.google.com
genericany HTTPS URL

Business / rich blocks

ComponentProps
product_table(proprietary container preserved — see notes)
mini_tableheader_service?, header_duration?, rows: { service, duration }[]
gallerycols, item_count (placeholders)

Cards

For each card type the DSL exposes both a standalone form and a grouped form. Grouped cards always assemble to a columns block with one card per column — proprietary containers (testimonialsContainer, accordionContainer, etc.) are deprecated and never produced.
StandaloneGroupedItems contain
numbered_calloutnumbered_callouts{ number, title, description, color }
process_cardprocess_cards{ number, title, description, color }
process_card_with_image(use columns + image)adds an image placeholder
stat_cardstat_cards{ value, label }
testimonial_cardtestimonial_cards{ quote, name, title, stars, star_color }
team_cardteam_cards{ name, role, bio }
pricing_cardpricing_cards{ name, description, price, features, button_text? }
case_study_cardcase_study_cards{ title, description, badges?, social_proof_*, link_*, orientation }
questionquestions{ question, answer, open? }
Grouped cards take a cols prop matching the visual layout ('1''4').

Section theming

Three accepted forms for section.section_style, in order of preference:
  1. 'default' — “Page” : the section inherits page-level colors. No section ambience.
  2. Named slot id (recommended) — e.g. 'soft', 'deep'. Resolved server-side via theme.section_styles[].id. Survives reordering of the theme’s slots.
  3. Positional index'1', '2', '3', … 1-indexed reference into the theme’s section_styles[] array. In stock themes: '1' = Default, '2' = Highlight, '3' = Contrast.
The legacy section.theme (white / gray / black / outlined) is inert when a custom theme is applied to the proposal — it only controls the legacy bodyTheme fallback on themeless proposals. Prefer section_style. See theming for the full picture.

Background image

{
  type: 'section',
  // ...
  background_image: {
    media_id: string,         // UUID of an asset uploaded via media_service
    overlay?: boolean,
    overlay_opacity?: number, // 0-100
    size?: 'cover' | 'contain' | 'auto' | 'custom',
    size_width?: string,      // when size === 'custom'
    size_height?: string,
    position_x?: string,      // CSS values (e.g. '50%', 'top')
    position_y?: string,
    repeat?: 'no-repeat' | 'repeat' | 'repeat-x' | 'repeat-y',
    attachment?: 'scroll' | 'fixed',
  },
}
media_id must reference an asset that already exists in the org’s media library. The server resolves the URL at assemble time.

Limits

LimitValue
MAX_SECTIONS per append10
MAX_DEPTH (columns nesting)6
MAX_NODES per tree200
MAX_BLOCKS_PER_SECTION50
MAX_BLOCKS_PER_COLUMN30
MAX_COLUMNS4
These are exposed in the limits field of list_propal_components.

End-to-end example

Build a 2-section proposal with a hero and three numbered callouts:
{
  "tree": [
    {
      "type": "section",
      "section_style": "default",
      "badge": "Welcome",
      "title": "Q2 Redesign Proposal",
      "description": "A complete refresh of the marketing site.",
      "children": [
        { "type": "divider" },
        {
          "type": "paragraph",
          "text": "We've reviewed your current site and identified three priorities."
        }
      ]
    },
    {
      "type": "section",
      "section_style": "highlight",
      "title": "Goals",
      "children": [
        {
          "type": "numbered_callouts",
          "cols": "3",
          "items": [
            { "number": "01", "title": "Faster", "description": "Cut TTI by 50%.", "color": "blue" },
            { "number": "02", "title": "Cleaner", "description": "Refresh visuals.", "color": "green" },
            { "number": "03", "title": "Convert", "description": "Better CTA placement.", "color": "purple" }
          ]
        }
      ]
    }
  ]
}
Send via:
append_propal_component_tree_to_proposal
  proposal_id: "<uuid>"
  tree: <above>
The server validates the tree, resolves named refs (here "highlight" if a matching slot id exists), assembles the TipTap nodes, and atomically appends them with optimistic concurrency control.

Round-trip stability

Reading a proposal via get_proposal_tree returns the same DSL shape you wrote — assembler defaults are stripped on read so the round-trip is a fixed point. Caveats:
  • Node types not in the DSL come back as { type: 'unknown', tiptap_type, text_preview, child_count }.
  • Old proposals containing legacy *Container nodes are flattened to the grouped DSL form (testimonial_cards, questions, …) on read only — they are never written back in that form.
  • Headings with level > 3 come back as unknown (the DSL only supports 1-3).