What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
Overview
Add your own buttons to the rich text editor. Insert and edit typed embeds from your app's dialog, render them live while authoring, and render them on your website.
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.
group collapse into one menu.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.
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:
textContent, not innerHTML, for anything an author or a third party typed.@agility/app-sdk 2.4.0 or later:
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 and the Apps SDK reference.
capabilities.rteToolbar is an array with one entry per button.
{
"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. |
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.
On this page, useAgilityAppSDK() returns an embed object:
{
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:
"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>
)
}
The value you pass to resolveEmbed is stored as JSON on the element. With the manifest above, the field's HTML gets:
<acme-flipcard class="agility-embed mceNonEditable"
data-agility-embed="flipcard"
data-agility-app="acme-flip-cards"
data-agility-value="{"front":"Hello","back":"World"}"></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.
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:
<script> tag, so an ES module never runs.data-mce-bogus="all". The editor drops those nodes when it saves, so your preview never ends up in the stored content.An editor script, at public/components/flipcard.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)
})()
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:
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.
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:
"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:
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.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.
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 reference.
The SDK's own docs cover every operation and its availability per surface:
rteToolbar