# Extending the Rich Text Editor with an App

> Source: https://agilitycms.com/docs/apps/extending-the-rich-text-editor

An Agility app can add its own buttons to the rich text editor. Each button inserts a typed **embed**: a structured piece of content that your app owns, such as a flip card, a product, a video or a Jira issue. Authors insert embeds and edit them again later, and your app supplies the dialog for both.

This surface is called `rteToolbar`. It works in every HTML field, including the Rich Text Area Component.

## What an app can do

- **Add toolbar buttons.** Your buttons appear after the built-in ones. Buttons that share a `group` collapse into one menu.
- **Insert and edit embeds.** A button opens your app in a dialog. The dialog returns a value, and the Content Manager writes the embed into the field. Clicking the embed later reopens the same dialog in edit mode, with the saved value.
- **Render embeds live in the editor (optional).** If you provide a web component for the embed, the editor shows the real thing instead of a placeholder.
- **Add Edit and Delete controls to each embed (optional).** Your web component can draw its own controls and ask the Content Manager to reopen or remove the embed.

The surface is additive. An app can't remove, replace or reorder the built-in buttons, and it can't change how they work.

It works for private apps and Marketplace apps. There is no feature flag: the buttons appear in an instance as soon as an app that declares `rteToolbar` is installed.

## Security: your app runs code inside the editor

> [!WARNING]
> An app that uses this surface runs its own code inside the rich text editor, with the same access as the person editing. The Content Manager tells admins this when they install or configure the app.

Every other app surface runs in a separate iframe on the app's own domain. The rich text editor is different. Its editing area shares the Content Manager's origin, so a script your app loads there can reach the Content Manager window and its `sessionStorage`, including the editing user's Management API token.

What that means in practice:

- **For admins:** only install apps from developers you trust. This applies to private apps too.
- **For app developers:** treat your editor script as privileged code. Never write untrusted input into the page as HTML. Use `textContent`, not `innerHTML`, for anything an author or a third party typed.

## Requirements

- `@agility/app-sdk` **2.4.0** or later:

  ```bash
  npm install @agility/app-sdk@^2.4.0
  ```

- An app that serves its manifest at `/.well-known/agility-app.json`. If you're new to apps, start with [Creating Apps for Agility](https://agilitycms.com/docs/apps/creating-apps-for-agility) and the [Apps SDK](https://agilitycms.com/docs/apps/apps-sdk) reference.

## 1. Declare your buttons in the manifest

`capabilities.rteToolbar` is an array with one entry per button.

```json
{
  "name": "Acme Flip Cards",
  "description": "Flip cards for course pages.",
  "version": "1.0.0",
  "configValues": [],
  "capabilities": {
    "rteToolbar": [
      {
        "name": "flipcard",
        "label": "Flip Card",
        "description": "Insert a flip card",
        "element": "acme-flipcard",
        "editorComponentUrl": "/components/flipcard.js",
        "icon": "embed",
        "group": "Learning Objects"
      }
    ]
  }
}
```

| Key | Required | What it does |
| --- | --- | --- |
| `name` | Yes | The button's id. It's the route segment of your dialog page, and it's written into each embed as `data-agility-embed`. |
| `label` | Recommended | The button's tooltip, its text in a group menu, and its name in the app's capability list. Falls back to `name`. |
| `description` | Recommended | A short description of what the button inserts. |
| `element` | No | The HTML tag the embed is stored as. Defaults to `div`. Set it to a custom element name if you want live rendering. See the naming rules below. |
| `editorComponentUrl` | No | A script that defines `element` as a web component, so the embed renders live in the editor. It must be on your app's own origin. |
| `icon` | No | A TinyMCE icon name. Defaults to `embed`. |
| `group` | No | Buttons from the same app that share a group collapse into one menu button. |

## 2. Serve the dialog page

When an author clicks your button, the Content Manager opens your app in a dialog at:

```
{your app URL}/rte-toolbar/{buttonName}?appID=…&closeModalID=…
```

So `"name": "flipcard"` needs a page at `/rte-toolbar/flipcard`. Keep the query string: the SDK needs both parameters.

## 3. Read the embed and resolve it

On this page, `useAgilityAppSDK()` returns an `embed` object:

```ts
{
  buttonName: string          // the button the author clicked
  mode: "insert" | "edit"     // a new embed, or reopening an existing one
  value?: unknown             // the saved value, when editing
  contentItem?: IContentItem  // the item the rich text field belongs to
}
```

When the author is done, call `resolveEmbed({ value })`. If they cancel, call `resolveEmbed({ cancelled: true })`. Always call one of them, or the dialog stays open.

Here is a complete dialog page for the Next.js App Router, at `app/rte-toolbar/flipcard/page.tsx`:

```tsx
"use client"

import { useEffect, useState } from "react"
import { useAgilityAppSDK, resolveEmbed } from "@agility/app-sdk"

interface FlipCardValue {
  front: string
  back: string
}

export default function FlipCardDialog() {
  const { initializing, embed } = useAgilityAppSDK()
  const [card, setCard] = useState<FlipCardValue>({ front: "", back: "" })

  // When editing, start from the value saved on the embed
  useEffect(() => {
    if (embed?.mode === "edit" && embed.value) {
      setCard(embed.value as FlipCardValue)
    }
  }, [embed])

  if (initializing || !embed) return null

  return (
    <div>
      <label>
        Front
        <input value={card.front} onChange={(e) => setCard({ ...card, front: e.target.value })} />
      </label>
      <label>
        Back
        <input value={card.back} onChange={(e) => setCard({ ...card, back: e.target.value })} />
      </label>
      <button onClick={() => resolveEmbed({ value: card })}>
        {embed.mode === "insert" ? "Insert" : "Save"}
      </button>
      <button onClick={() => resolveEmbed({ cancelled: true })}>Cancel</button>
    </div>
  )
}
```

### What the Content Manager writes

The value you pass to `resolveEmbed` is stored as JSON on the element. With the manifest above, the field's HTML gets:

```html
<acme-flipcard class="agility-embed mceNonEditable"
               data-agility-embed="flipcard"
               data-agility-app="acme-flip-cards"
               data-agility-value="{&quot;front&quot;:&quot;Hello&quot;,&quot;back&quot;:&quot;World&quot;}"></acme-flipcard>
```

- `data-agility-embed` is the button's `name`.
- `data-agility-app` is your app's `name` from the manifest, lowercased, with anything that isn't a letter or digit turned into a hyphen.
- `data-agility-value` is your value as JSON. It's the only storage the surface offers, so keep all of the embed's settings in it.

The embed is a single, non-editable block in the editor. When the author clicks it, the Content Manager reopens your dialog with `mode: "edit"` and the saved `value`. Double-click, and Enter on a selected embed, do the same.

## 4. Optional: render the embed live in the editor

Without a component, the editor shows each embed as a labelled placeholder card with the button name and app name. To show the real thing, declare both `element` and `editorComponentUrl`. The editor loads your script and the browser upgrades each embed.

Rules for the editor script:

- **It must be a classic script, not an ES module.** Ship an IIFE or UMD bundle. The editor loads it with a plain `<script>` tag, so an ES module never runs.
- **It must be on your app's own origin.** A relative path resolves against your app's URL. A URL on any other origin is refused.
- **Mark everything you draw with `data-mce-bogus="all"`.** The editor drops those nodes when it saves, so your preview never ends up in the stored content.
- **Keep it non-interactive and avoid shadow DOM.** The embed is non-editable, so interaction fights the editor, and the editor's selection model doesn't handle shadow roots. Show both faces of a flip card at once rather than making the author click to flip it.

An editor script, at `public/components/flipcard.js`:

```js
(function () {
  if (customElements.get("acme-flipcard")) return

  class AcmeFlipcard extends HTMLElement {
    static get observedAttributes() {
      return ["data-agility-value"]
    }

    connectedCallback() {
      this.render()
    }

    attributeChangedCallback() {
      this.render()
    }

    render() {
      let value = {}
      try {
        value = JSON.parse(this.getAttribute("data-agility-value") || "{}")
      } catch (e) {}

      // Remove the previous preview, then draw a new one
      this.querySelectorAll("[data-acme-preview]").forEach((node) => node.remove())

      const preview = document.createElement("div")
      preview.setAttribute("data-acme-preview", "")
      preview.setAttribute("data-mce-bogus", "all") // never saved

      const front = document.createElement("p")
      front.textContent = "Front: " + (value.front || "")
      const back = document.createElement("p")
      back.textContent = "Back: " + (value.back || "")

      preview.append(front, back)
      this.appendChild(preview)
    }
  }

  customElements.define("acme-flipcard", AcmeFlipcard)
})()
```

### Edit and Delete controls

An author deletes an embed by pressing Escape to close the dialog that a click opens, then pressing Delete. If you want a more direct way, your editor script can draw its own Edit and Delete buttons and ask the Content Manager to act. Dispatch a bubbling `agility-embed-action` event on the embed element:

```js
const del = document.createElement("button")
del.type = "button"
del.textContent = "Delete"
del.setAttribute("data-agility-action", "delete")
del.addEventListener("click", (e) => {
  e.preventDefault()
  e.stopPropagation()
  this.dispatchEvent(
    new CustomEvent("agility-embed-action", {
      bubbles: true,
      detail: { action: "delete", button: "flipcard", app: "acme-flip-cards" }
    })
  )
})
preview.appendChild(del)
```

`action` is `"edit"` or `"delete"`. The Content Manager reopens your dialog for `edit`, and removes the embed as one undoable step for `delete`. Neither works while the field is read-only or saving. Draw the controls inside your `data-mce-bogus="all"` preview so they're never saved.

## 5. Render embeds on your website

The editor component only runs in the Content Manager. Your website doesn't get it.

The Fetch API returns the HTML field exactly as stored, so your site receives the raw element, such as `<acme-flipcard data-agility-value="…">`. Until your site does something with it, the browser treats it as an unknown element and shows nothing. Agility doesn't provide a renderer for embeds. You choose how to render them. There are two common approaches.

**Register a web component on your site.** Define the same tag in your site's own JavaScript. This is a separate implementation from the editor script, so it can be interactive and it can use shadow DOM. In Next.js, register it from a client component and render that component once, in your root layout:

```tsx
"use client"

import { useEffect } from "react"

export default function RegisterEmbeds() {
  useEffect(() => {
    if (customElements.get("acme-flipcard")) return

    class AcmeFlipcard extends HTMLElement {
      connectedCallback() {
        if (this.shadowRoot) return
        const value = JSON.parse(this.getAttribute("data-agility-value") || "{}")
        const root = this.attachShadow({ mode: "open" })
        const card = document.createElement("button")
        card.textContent = value.front ?? ""
        card.addEventListener("click", () => {
          card.textContent = card.textContent === value.front ? value.back : value.front
        })
        root.appendChild(card)
      }
    }

    customElements.define("acme-flipcard", AcmeFlipcard)
  }, [])

  return null
}
```

Elements that are already on the page upgrade as soon as the tag is defined. That includes rich text you render with `dangerouslySetInnerHTML`.

**Or replace embeds with your own components.** Parse the HTML field on the server, find each element whose `data-agility-embed` matches your button, and render your own React component in its place, with its props read from `data-agility-value`. Any HTML parser works.

Either way:

- Match on `data-agility-embed` (and `data-agility-app` if you use more than one app), not on the class.
- `data-agility-value` is HTML-escaped in the markup. `getAttribute` returns plain JSON, ready for `JSON.parse`.
- If your site loads a script hosted by the app's developer, allow that origin in your Content Security Policy.

## Rules and limitations

**Element names.** `element` must be a valid custom element name: lowercase, starting with a letter, containing a hyphen, and not a reserved name (such as `font-face`). Names starting with `mce-` or `tox-` are refused. A button with an invalid `element` doesn't appear, and the browser console says why. Prefix your elements with your company or app name, such as `acme-flipcard`.

**Collisions between apps.** If two installed apps declare the same `element`, or have names that turn into the same `data-agility-app` value, only one keeps it. The Content Manager sorts apps by name, and the first app in that order wins. The other app's button is skipped, with an error in the browser console.

**Renaming a published app orphans its embeds.** Embeds find their app by `data-agility-app`, which comes from the app's `name`. Once authors have used your app, don't change its name. The existing embeds stay in the content, but the editor can no longer open them with your dialog.

**The value is stored in the HTML, nothing else.** There is no reference counting. If your value points at something outside the field, such as a content item your app created, deleting the embed leaves that item behind, and copying and pasting the embed gives you two embeds pointing at the same item.

**Buttons appear in every HTML field.** An app's buttons show in every HTML field on every model in the instance. You can't limit them to particular models or fields yet.

**Uninstalling an app keeps its embeds.** The editor preserves every custom element it finds in a field's content, whether or not an installed app declares it. After an uninstall, the embeds stay in your content, unchanged, and the editor no longer opens a dialog for them. Reinstall the app and they work again.

## Navigating from your app

SDK 2.4.0 also adds navigation, on every surface. `navigate()` takes the Content Manager to a content item, a new item, a content list or a page. `getNavigationUrl()` returns the address for the same destination, so you can render a real link. See the [Apps SDK](https://agilitycms.com/docs/apps/apps-sdk) reference.

## Full reference

The SDK's own docs cover every operation and its availability per surface:

- [Surfaces, including `rteToolbar`](https://github.com/agility/agility-cms-app-sdk/blob/main/docs/surfaces.md)
- [API reference](https://github.com/agility/agility-cms-app-sdk/blob/main/docs/api-reference.md)
- [Troubleshooting](https://github.com/agility/agility-cms-app-sdk/blob/main/docs/troubleshooting.md), including what to check when an embed's component doesn't render in the editor

---

## Related pages

- [Creating Apps for Agility ](https://agilitycms.com/docs/apps/creating-apps-for-agility.md)
- [Apps SDK](https://agilitycms.com/docs/apps/apps-sdk.md)
- [App Types In Agility](https://agilitycms.com/docs/apps/apps-in-agility.md)
- [Developing Apps Locally: New and Existing](https://agilitycms.com/docs/apps/developing-apps-locally-new-and-existing.md)
- [Building Apps with AI Coding Tools](https://agilitycms.com/docs/apps/building-apps-with-ai-coding-tools.md)
- [How We Built the Google Analytics Page Sidebar with Claude](https://agilitycms.com/docs/apps/building-the-google-analytics-page-sidebar-with-claude.md)

Index of every docs page: https://agilitycms.com/docs/llms.txt
