> ## Documentation Index
> Fetch the complete documentation index at: https://docs.feedal.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Content type plugins

> Build a custom question type for Feedal using the sandboxed iframe model and @feedal/content-type-sdk.

Content type plugins let you add new question types to Feedal. Your UI runs in a sandboxed iframe; the host page communicates with it via a `postMessage` protocol. The `@feedal/content-type-sdk` package provides a minimal bridge so you don't have to implement the protocol yourself.

***

## Security model

Community plugin UI **never runs in the Feedal origin**. Your bundle is loaded in an iframe with `sandbox="allow-scripts"` only:

* No access to the parent page's DOM, localStorage, or cookies
* No cross-origin network requests from the iframe (except to your own CDN for the bundle itself)
* `postMessage` is the only communication channel between your plugin and Feedal

This means respondent data stays in Feedal's hands — your iframe receives the current answer value and node config, and can only send back an answer value or a size hint.

***

## How it works

```
Feedal host page
│
├── sends: PLUGIN_RENDER (node, mode, currentValue)
│
└─── iframe (your bundle, sandboxed)
      ├── calls: signalReady()          → parent receives PLUGIN_READY
      ├── calls: sendAnswer(value)      → parent receives PLUGIN_ANSWER
      └── calls: sendSize(height)       → parent receives PLUGIN_SIZE
```

***

## Installing the SDK

```bash theme={null}
npm install @feedal/content-type-sdk
# or
pnpm add @feedal/content-type-sdk
```

***

## SDK reference

```typescript theme={null}
import { onRender, sendAnswer, signalReady, sendSize } from "@feedal/content-type-sdk";

// Tell Feedal your iframe is ready to receive renders
signalReady();

// Listen for render events (called every time the node is shown or the value changes)
const unsubscribe = onRender((payload) => {
  const { node, mode, value } = payload;
  // node   — the full GraphNode object (id, question_type, data.config, etc.)
  // mode   — "preview" | "interactive" | "readonly"
  // value  — the current answer value (null if unanswered)
  renderMyUI(node, mode, value);
});

// Send the respondent's answer back to Feedal
sendAnswer("the answer value");

// Tell Feedal to resize the iframe (call when your content height changes)
sendSize(350);  // height in pixels
```

### `onRender(callback)`

Registers a callback that fires every time Feedal sends a `PLUGIN_RENDER` message. Returns an unsubscribe function.

The `RenderPayload` passed to the callback:

| Field   | Type                                       | Description                           |
| ------- | ------------------------------------------ | ------------------------------------- |
| `node`  | `Record<string, unknown>`                  | Full node object from the graph       |
| `mode`  | `"preview" \| "interactive" \| "readonly"` | Current render context                |
| `value` | `unknown`                                  | Current answer value (`null` if none) |

### `sendAnswer(value)`

Sends the respondent's answer to the host page. Call this when the user makes a selection or submits input. `value` can be any JSON-serialisable type.

### `signalReady()`

Signals to the host page that your iframe has finished loading and is ready to receive `PLUGIN_RENDER` messages. Call this once, after your bundle is initialised.

### `sendSize(height)`

Requests the host page to resize the iframe to `height` pixels. Call this whenever your content changes height to avoid scroll bars or clipped content.

***

## Raw postMessage protocol

If you're not using the SDK, here are the raw message types:

**Host → iframe**

| `type`          | Additional fields       | Description                        |
| --------------- | ----------------------- | ---------------------------------- |
| `PLUGIN_RENDER` | `node`, `mode`, `value` | Render the question with this data |

**iframe → host**

| `type`          | Additional fields | Description                    |
| --------------- | ----------------- | ------------------------------ |
| `PLUGIN_READY`  | —                 | iframe has initialised         |
| `PLUGIN_ANSWER` | `value`           | Respondent submitted an answer |
| `PLUGIN_SIZE`   | `height`          | Request iframe resize          |
| `PLUGIN_ERROR`  | —                 | Fatal load failure             |

***

## Building your bundle

Your renderer must be a self-contained ES module bundle served over HTTPS. The `renderer_bundle_url` in your manifest must point to this file.

```bash theme={null}
# Example using Vite
vite build --lib --entry src/index.ts --formats es
```

The bundle is loaded into the iframe as a `<script type="module">`. There is no HTML shell — your bundle is responsible for creating DOM elements and appending them to `document.body`.

***

## Minimal example

```typescript theme={null}
import { onRender, sendAnswer, signalReady, sendSize } from "@feedal/content-type-sdk";

// Build UI using safe DOM methods (never insert untrusted data via innerHTML)
const container = document.createElement("div");
document.body.appendChild(container);

onRender(({ node, mode, value }) => {
  // Clear previous content safely
  while (container.firstChild) container.removeChild(container.firstChild);

  if (mode === "readonly") {
    const text = document.createElement("span");
    text.textContent = String(value ?? "—");   // textContent is XSS-safe
    container.appendChild(text);
    sendSize(40);
    return;
  }

  const input = document.createElement("input");
  input.type = "text";
  input.value = String(value ?? "");
  input.placeholder = String(node?.data?.config?.placeholder ?? "Enter value");

  input.addEventListener("change", () => {
    sendAnswer(input.value);
  });

  container.appendChild(input);
  sendSize(60);
});

signalReady();
```

***

## Validation

### What the framework handles

Feedal enforces one rule on behalf of every question type, including plugins: **required fields**. If `node.data.is_required` is `true` and the respondent hasn't submitted an answer yet, the runner blocks navigation and shows "This field is required." before your plugin ever receives a `PLUGIN_RENDER` event for the next node.

That's the only validation the framework performs for plugins. Everything else is your responsibility.

### What your plugin handles

All content-level validation — format checks, range limits, minimum selections, custom business rules — must be enforced inside the iframe. The pattern is:

1. Show the input and let the respondent interact.
2. When input is invalid, display an inline error message inside the iframe and **do not call `sendAnswer()`**.
3. Only call `sendAnswer(value)` once the value is valid.

Because the runner only advances when it receives a `PLUGIN_ANSWER` message, withholding `sendAnswer()` is the complete and correct way to block navigation. There is no separate validation API.

```typescript theme={null}
// Example: only send a valid answer
input.addEventListener("change", () => {
  const raw = input.value.trim();
  if (raw.length < 3) {
    errorEl.textContent = "Please enter at least 3 characters.";
    return; // do NOT call sendAnswer — navigation stays blocked
  }
  errorEl.textContent = "";
  sendAnswer(raw);
});
```

### The `needsContinueButton` manifest field

By default, plugins behave like choice or rating questions: the runner expects `sendAnswer()` to be called on a single user action and does not render an explicit Continue button.

If your plugin has a multi-step input or needs the respondent to explicitly confirm before advancing (for example, a signature pad or a file picker with a preview step), set `needs_continue_button: true` in your manifest:

```json theme={null}
{
  "type": "content_type",
  "question_type_id": "signature-capture",
  "display_name": "Signature Capture",
  "renderer_bundle_url": "https://cdn.example.com/signature-plugin/v1.0.0/bundle.esm.js",
  "version": "1.0.0",
  "icon": "✍️",
  "needs_continue_button": true
}
```

When `needs_continue_button` is `true`, Feedal renders a themed Continue button below your iframe — the same button used for text input questions. The respondent clicks it to advance; your plugin should already have called `sendAnswer()` with the current value before that click.

If `needs_continue_button` is `false` (the default), no Continue button is rendered. Your plugin should call `sendAnswer()` as the final confirmation action.

### `answerSchema`

The `answerSchema` field in the manifest is informational only. It documents the shape of the value you pass to `sendAnswer()` and is used for type hints in the developer portal and the Results Hub. Feedal does not validate answer values against this schema at runtime.

***

## Registering with the manifest

Add a `content_type` extension point to your [app manifest](/developer/developer-portal#manifest-schema):

```json theme={null}
{
  "type": "content_type",
  "question_type_id": "signature-capture",
  "display_name": "Signature Capture",
  "renderer_bundle_url": "https://cdn.example.com/signature-plugin/v1.0.0/bundle.esm.js",
  "version": "1.0.0",
  "icon": "✍️"
}
```

Once your app is approved in the marketplace, the `question_type_id` becomes available in the studio's **Add content** panel and in the form graph as `"question_type": "plugin:signature-capture"`.

***

## Testing locally

During development, simulate the postMessage protocol in a plain HTML file to test your bundle without a live Feedal account:

```html theme={null}
<!DOCTYPE html>
<html>
<body>
  <iframe id="plugin" sandbox="allow-scripts"></iframe>
  <script type="module">
    const frame = document.getElementById("plugin");

    window.addEventListener("message", (e) => {
      if (e.data.type === "PLUGIN_READY") {
        frame.contentWindow.postMessage({
          type: "PLUGIN_RENDER",
          node: { id: "test", data: { config: {} } },
          mode: "interactive",
          value: null,
        }, "*");
      }
      if (e.data.type === "PLUGIN_ANSWER") {
        console.log("Answer received:", e.data.value);
      }
    });
  </script>
</body>
</html>
```

Point the iframe `src` to a local server that serves your bundle.

***

## Next steps

<CardGroup cols={2}>
  <Card title="Developer portal" icon="code-2" href="/developer/developer-portal">
    Submit your plugin for marketplace review.
  </Card>

  <Card title="Marketplace" icon="store" href="/integrations/marketplace">
    Browse community and official plugins.
  </Card>
</CardGroup>
