> ## 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.

# Components DSL

> The typed grammar agents use to build proposal content.

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:

<CardGroup cols={2}>
  <Card title="list_propal_components" icon="list">
    Returns the full catalog with descriptions, structural rules, limits, and
    a compact example per component.
  </Card>

  <Card title="get_propal_component_schema" icon="braces">
    Returns the detailed Zod-derived schema for a single component
    (`{ component_type: 'section' | 'columns' | ... }`).
  </Card>
</CardGroup>

The descriptions in those tools are the source of truth — this page mirrors
them but the live tool always wins.

## Layout

| Component | Props                                                                                                                                    | Notes                                                                                                      |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `section` | `theme?`, `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](/mcp/theming). |
| `columns` | `cols: '2'\|'3'\|'4'`, `column_widths?`, `columns: Block[][]`                                                                            | One sub-tree per column. `columns.length` must equal `cols`.                                               |
| `spacer`  | `height?`                                                                                                                                | Vertical space.                                                                                            |

## Basic blocks

| Component     | Props                                     |
| ------------- | ----------------------------------------- |
| `paragraph`   | `text`, `align?`                          |
| `heading`     | `level: 1\|2\|3`, `text`, `align?`        |
| `bullet_list` | `items: string[]`                         |
| `divider`     | (no props — renders `horizontalRule`)     |
| `badge`       | `text`, `color`, `size`, `rounded`        |
| `stars`       | `count`, `color`                          |
| `profile`     | `name`, `title` (avatar is a placeholder) |

## Media

**Placeholders only — no external URLs:**

| Component | Maps to                                    |
| --------- | ------------------------------------------ |
| `image`   | `imageUpload` (`componentUpload: 'block'`) |
| `icon`    | `imageUpload` (`componentUpload: 'icon'`)  |
| `video`   | `videoUpload`                              |

The user uploads the actual asset in the editor after the agent has built the
structure.

**With URL:**

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  type: 'embed',
  provider: 'youtube' | 'loom' | 'vimeo' | 'figma' | 'drive' | 'generic',
  src: string, // HTTPS, validated against the provider's host allowlist
  height?: string,
}
```

Allowed hosts:

| Provider  | Hosts                                 |
| --------- | ------------------------------------- |
| `youtube` | `youtube.com`, `youtu.be`             |
| `loom`    | `loom.com`                            |
| `vimeo`   | `vimeo.com`, `player.vimeo.com`       |
| `figma`   | `figma.com`                           |
| `drive`   | `drive.google.com`, `docs.google.com` |
| `generic` | any HTTPS URL                         |

## Business / rich blocks

| Component       | Props                                                                  |
| --------------- | ---------------------------------------------------------------------- |
| `product_table` | (proprietary container preserved — see notes)                          |
| `mini_table`    | `header_service?`, `header_duration?`, `rows: { service, duration }[]` |
| `gallery`       | `cols`, `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.

| Standalone                | Grouped                 | Items contain                                                          |
| ------------------------- | ----------------------- | ---------------------------------------------------------------------- |
| `numbered_callout`        | `numbered_callouts`     | `{ number, title, description, color }`                                |
| `process_card`            | `process_cards`         | `{ number, title, description, color }`                                |
| `process_card_with_image` | (use `columns` + image) | adds an image placeholder                                              |
| `stat_card`               | `stat_cards`            | `{ value, label }`                                                     |
| `testimonial_card`        | `testimonial_cards`     | `{ quote, name, title, stars, star_color }`                            |
| `team_card`               | `team_cards`            | `{ name, role, bio }`                                                  |
| `pricing_card`            | `pricing_cards`         | `{ name, description, price, features, button_text? }`                 |
| `case_study_card`         | `case_study_cards`      | `{ title, description, badges?, social_proof_*, link_*, orientation }` |
| `question`                | `questions`             | `{ 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](/mcp/theming) for the full picture.

### Background image

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  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

| Limit                         | Value |
| ----------------------------- | ----- |
| `MAX_SECTIONS` per append     | 10    |
| `MAX_DEPTH` (columns nesting) | 6     |
| `MAX_NODES` per tree          | 200   |
| `MAX_BLOCKS_PER_SECTION`      | 50    |
| `MAX_BLOCKS_PER_COLUMN`       | 30    |
| `MAX_COLUMNS`                 | 4     |

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:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "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).
