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
The Agility Apps SDK v2 (@agility/app-sdk) enables developers to build powerful custom applications that extend and integrate with Agility CMS. This SDK provides a comprehensive set of tools for creating custom fields, sidebars, dashboards, modals, rich text editor buttons, and screens of your own: a section in the main navigation, screens on the item stack, and reports.
Important: Your app doesn't need to implement all surfaces. You can create an app that provides just a single custom field, or only a sidebar, or any combination of surfaces that meets your needs. The SDK is designed to be flexible—implement only what you need.
npm install @agility/app-sdk@^2.5.0
Version 2.5.0 adds Sections, Stack Items, Reports, the user context and link buttons for the rich text editor. Version 2.4.0 added the Rich Text Editor Toolbar surface and Navigation Methods. Apps that use any of these need that version or later. Every 2.4 app keeps working unchanged on 2.5.0.
Every Agility App must include an app definition file located at /.well-known/agility-app.json. This JSON file defines the metadata, capabilities, configuration values, and surfaces that your app provides.
You only need to define the surfaces your app actually implements. If you're building a simple custom field, you only need to include the fields capability. If you're building a dashboard, you only need the relevant dashboard capability. The example below shows all possible capabilities, but your app can include just one or a subset of these.
{
"name": "Example App - SDK v2",
"documentationLink": "https://github.com/agility/agility-cms-apps",
"description": "An Example App to show what the Agility App SDK V2 is capable of.",
"version": "1.0.0",
"__sdkVersion": "2.0.0",
"configValues": [
{
"label": "Config Value 1",
"name": "configValue1",
"type": "string"
}
],
"connections": [
{
"name": "Agility API Offline Access",
"icon": "https://cdn.aglty.io/content-manager/images/logo-triangle-only-yellow.svg",
"url": "/oauth/agility-api-offline"
}
],
"capabilities": {
"fields": [
{
"name": "example-field",
"label": "Example Field",
"description": "An example field that shows a custom UI."
}
],
"modals": [
{
"name": "example-modal",
"label": "Example Modal Dialog",
"description": "An example modal dialog."
}
],
"contentListSidebar": {
"description": "Shows example data or ui that could be useful on a content list."
},
"contentItemSidebar": {
"description": "Shows example data or ui that could be useful on a content item."
},
"pageSidebar": {
"description": "Shows example data or ui that could be useful on a page."
},
"homeDashboard": {
"description": "Shows data and analytics relevant to the entire site."
},
"contentDashboard": {
"description": "Shows data and analytics relevant to the Content section."
},
"pagesDashboard": {
"description": "Shows data and analytics relevant to the Pages section."
},
"sections": [
{
"name": "my-library",
"label": "My Library",
"description": "Browse the library.",
"icon": "IconFolder"
}
],
"items": [
{
"name": "libraryfolder",
"label": "Folder",
"description": "A folder in the library."
}
],
"reports": [
{
"name": "usage-by-day",
"label": "Usage by day",
"description": "Downloads per day."
}
],
"rteToolbar": [
{
"name": "flipcard",
"label": "Flip Card",
"description": "Insert a flip card."
}
],
"installScreen": true
}
}
You don't need to include all capabilities. Here are some minimal examples:
Just a Custom Field:
{
"name": "My Custom Field",
"version": "1.0.0",
"__sdkVersion": "2.0.0",
"capabilities": {
"fields": [
{
"name": "my-field",
"label": "My Custom Field",
"description": "A custom field for editing content."
}
]
}
}
Just a Content Item Sidebar:
{
"name": "Content Helper",
"version": "1.0.0",
"__sdkVersion": "2.0.0",
"capabilities": {
"contentItemSidebar": {
"description": "Provides helpful information when editing content."
}
}
}
Just a Dashboard:
{
"name": "Analytics Dashboard",
"version": "1.0.0",
"__sdkVersion": "2.0.0",
"capabilities": {
"homeDashboard": {
"description": "Shows analytics for your site."
}
}
}
The following sections describe all available surfaces. Remember: you only need to implement the surfaces that make sense for your app.
Custom fields allow you to create entirely custom UI components for content editing. Fields can be configured on any content definition and provide a custom editing experience.
Location: /app/fields/{field-name}/page.tsx
Example:
"use client"
import { useAgilityAppSDK, contentItemMethods, useResizeHeight } from "@agility/app-sdk"
export default function ExampleField() {
const { initializing, appInstallContext, fieldValue } = useAgilityAppSDK()
const containerRef = useResizeHeight(10)
if (initializing) return null
return (
<div>
<div ref={containerRef}>
<textarea
className="w-full rounded border border-gray-300 p-4"
value={fieldValue as string}
onChange={(e) => contentItemMethods.setFieldValue({ value: e.target.value })}
/>
</div>
</div>
)
}
Key Capabilities:
fieldValuecontentItemMethods.setFieldValue()useResizeHeight()contentItemcontentItemMethods.addFieldListener()assetsMethods.selectAssets()openModal()App Definition:
"fields": [
{
"name": "example-field",
"label": "Example Field",
"description": "An example field that shows a custom UI."
}
]
Sidebars provide contextual information and actions alongside different areas of the Agility CMS interface.
Appears when editing a specific content item.
Location: /app/content-item-sidebar/page.tsx
Example:
"use client"
import { contentItemMethods, openModal, useAgilityAppSDK, assetsMethods } from "@agility/app-sdk"
import { useEffect, useState } from "react"
export default function ContentItemSidebar() {
const { initializing, locale, contentItem } = useAgilityAppSDK()
const [heading, setHeading] = useState()
useEffect(() => {
setHeading(contentItem?.values.Heading)
}, [contentItem, contentItem?.values.Heading])
return (
<div>
<p>Heading: {heading}</p>
<button
onClick={async () => {
await contentItemMethods.saveContentItem()
}}
>
Save
</button>
<button
onClick={async () => {
await contentItemMethods.addFieldListener({
fieldName: "Heading",
onChange: (value) => setHeading(value)
})
}}
>
Add Field Listener
</button>
</div>
)
}
Key Capabilities:
contentItemcontentItemMethods.getContentItem()contentItemMethods.saveContentItem()contentItemMethods.addFieldListener()assetsMethods.selectAssets()openModal()App Definition:
"contentItemSidebar": {
"description": "Shows example data or ui that could be useful on a content item."
}
Appears when viewing a list of content items.
Location: /app/content-list-sidebar/page.tsx
Example:
"use client"
import { openModal, useAgilityAppSDK, contentItemMethods, refresh } from "@agility/app-sdk"
import { useState } from "react"
export default function ContentListSidebar() {
const { initializing, locale } = useAgilityAppSDK()
const [selected, setSelectedItems] = useState<any>([])
return (
<div>
<div>Selected: {JSON.stringify(selected)}</div>
<button onClick={() => refresh()}>Refresh List</button>
<button
onClick={async () => {
contentItemMethods.addSelectedItemListener({
onChange: (items) => setSelectedItems(items)
})
}}
>
Add Selection Listener
</button>
</div>
)
}
Key Capabilities:
contentItemMethods.addSelectedItemListener()contentItemMethods.getSelectedItems()refresh()contentItemMethods.removeSelectedItemListener()App Definition:
"contentListSidebar": {
"description": "Shows example data or ui that could be useful on a content list."
}
Appears when editing a page in the Pages section.
Location: /app/page-sidebar/page.tsx
Example:
"use client"
import { useAgilityAppSDK, pageMethods, assetsMethods } from "@agility/app-sdk"
import { useState } from "react"
export default function PagesSidebar() {
const { initializing, locale, pageItem } = useAgilityAppSDK()
const [page, setPage] = useState<IPageItem | null>()
return (
<div>
<button
onClick={async () => {
const pageData = await pageMethods.getPageItem()
setPage(pageData)
}}
>
Get Page Item
</button>
<div>Page: {JSON.stringify(page)}</div>
</div>
)
}
Key Capabilities:
pageItem from the hook (populated from SDK 2.4.0; on older versions it is always null, so call pageMethods.getPageItem() instead)pageMethods.getPageItem()assetsMethods.selectAssets()openModal()App Definition:
"pageSidebar": {
"description": "Shows example data or ui that could be useful on a page."
}
Dashboards provide high-level analytics and actions for different sections of Agility CMS.
Main dashboard visible on the home screen.
Location: /app/home-dashboard/page.tsx
Dashboard visible in the Content section.
Location: /app/content-dashboard/page.tsx
Dashboard visible in the Pages section.
Location: /app/pages-dashboard/page.tsx
Common Example:
"use client"
import { useAgilityAppSDK, useResizeHeight, assetsMethods, openModal } from "@agility/app-sdk"
export default function Dashboard() {
const { initializing, locale, appInstallContext } = useAgilityAppSDK()
const ref = useResizeHeight()
return (
<div ref={ref}>
<h1>Dashboard</h1>
<div>Locale: {locale}</div>
<div>Config: {JSON.stringify(appInstallContext?.configuration)}</div>
<button
onClick={() => {
assetsMethods.selectAssets({
title: "Select Assets",
singleSelectOnly: false,
callback: (assets) => console.log(assets)
})
}}
>
Select Assets
</button>
</div>
)
}
Key Capabilities:
appInstallContext.configurationconfigMethods.getConfigValue()useResizeHeight()assetsMethods.selectAssets()openModal()App Definition:
"homeDashboard": {
"description": "Shows data and analytics relevant to the entire site."
},
"contentDashboard": {
"description": "Shows data and analytics relevant to the Content section."
},
"pagesDashboard": {
"description": "Shows data and analytics relevant to the Pages section."
}
Modals are dialog windows that can be opened from any other surface in your app.
Location: /app/modals/{modal-name}/page.tsx
Example:
"use client"
import { closeModal, useAgilityAppSDK } from "@agility/app-sdk"
export default function ExampleModal() {
const { initializing, modalProps } = useAgilityAppSDK()
if (initializing) return <div>Initializing...</div>
return (
<div className="flex h-full flex-col">
<h2>Example Modal</h2>
<div className="flex-1">
<div>Modal Props: {JSON.stringify(modalProps)}</div>
</div>
<div className="flex gap-2">
<button onClick={() => closeModal({ btn: "ok" })}>OK</button>
<button onClick={() => closeModal({ btn: "cancel" })}>Cancel</button>
</div>
</div>
)
}
Opening a Modal:
openModal({
title: "Example Modal",
name: "example-modal",
props: {
customData: "value",
timestamp: new Date()
},
callback: (result) => {
console.log("Modal returned:", result)
}
})
Key Capabilities:
modalPropscloseModal(result)App Definition:
"modals": [
{
"name": "example-modal",
"label": "Example Modal Dialog",
"description": "An example modal dialog."
}
]
New in 2.5.0. A section gives your app its own entry in the main navigation and a full-pane screen inside the Content Manager's normal header, breadcrumb and back button. The Content Manager draws the chrome; your app paints the pane.
There is no feature flag. The entry appears as soon as an app that declares a section is installed, for every user who has the section's permissions.
Location: /app/sections/{section-name}/page.tsx
The Content Manager opens your app at /sections/{sectionName}?appID=…. The frame fills the pane, so you don't need useResizeHeight().
Example:
"use client"
import { useAgilityAppSDK, navigate } from "@agility/app-sdk"
export default function LibrarySection() {
const { initializing, section, user } = useAgilityAppSDK()
if (initializing) return null
return (
<div>
<h1>Library</h1>
<button onClick={() => navigate({ target: { type: "appItem", name: "libraryfolder", id: "12" } })}>
Open folder 12
</button>
</div>
)
}
App Definition:
"sections": [
{
"name": "my-library",
"label": "My Library",
"description": "Browse the library.",
"icon": "IconFolder",
"iconUrl": "/my-library-icon.svg",
"permissions": [1],
"panel": true
}
]
content, media, pages, reports, settings and so on).IconApps, IconArchive, IconBook, IconBriefcase, IconChartBar, IconChecklist, IconDatabase, IconFile, IconFiles, IconFolder, IconFolders, IconLock, IconPhoto, IconShieldLock, IconTable). Anything else falls back to IconApps.Access-Control-Allow-Origin: *. Draw it like the Content Manager's icons: a 0 0 20 20 viewBox, fill="none", stroke="currentColor" and no stroke width. The Content Manager redraws it from simple shapes only, and shows icon instead if the file is on another origin, fails to load, or uses anything else.true gives the section a navigation panel. See below.A name that breaks the rules is logged in the browser console and skipped. When two installed apps declare the same name, the first by app name keeps it.
With "panel": true, the Content Manager loads a second page, /sections/{sectionName}/panel, in the section's resizable left column, the same column Content and Assets use for their trees. It stays loaded while the right side shows the section's home or any item opened on top of it. In the panel, item and parent update as the user moves around, so the panel can highlight what's open without reloading.
setTitle from the panel is ignored. The heading belongs to the screen on the right.Don't build the section's address yourself. Use getNavigationUrl({ target: { type: "appSection", name } }).
New in 2.5.0. A stack item is one of your app's screens on the Content Manager's item stack, with its own URL, breadcrumb and back button. It can open on top of your app's section, or on top of a content item, a list, a page or a report. Use it for any screen worth linking to, such as one folder.
Location: /app/items/{item-name}/page.tsx
The Content Manager opens your app at /items/{itemName}?appID=…. The item's id isn't in the route: it arrives in the context, so one page serves every item of that name.
Example:
"use client"
import { useEffect } from "react"
import { useAgilityAppSDK, setTitle } from "@agility/app-sdk"
export default function FolderItem() {
const { initializing, item, parent } = useAgilityAppSDK()
useEffect(() => {
if (item) setTitle({ title: `Folder ${item.id}` })
}, [item])
if (initializing || !item) return null
// item: { name: "libraryfolder", id: "12" }
// parent: what the item opened on top of, e.g. { name: "listitem", id: "1001" }, or null
return <div>Folder {item.id}</div>
}
App Definition:
"items": [
{
"name": "libraryfolder",
"label": "Folder",
"description": "A folder in the library."
}
]
{name}-{id} URL segment). It must be unique across the installed apps and can't be one of the Content Manager's own stack names (page, item, list, listitem, folder, report and the like).setTitle.. _ ~ -. A UUID works. Encode anything else, for example as base64url.Setting the title: setTitle({ title }) replaces the item's heading and breadcrumb, and on a section's home screen, its heading. It doesn't change the browser tab's title. Until you call it, the Content Manager shows the manifest label.
Opening an item: navigate by intent, never by URL.
// On top of the current screen
navigate({ target: { type: "appItem", name: "libraryfolder", id: "12" } })
// Swap the top of the stack instead of adding to it, e.g. folder to folder
navigate({ target: { type: "appItem", name: "libraryfolder", id: "13", replace: true } })
// Back to the section's home
navigate({ target: { type: "appSection", name: "my-library" } })
From a screen with no stack, such as a dashboard, or with openInStack: false, the item opens inside your app's first section. These targets only reach your own app's sections and items.
New in 2.5.0. The reports surface puts your app's reports in the Content Manager's Reports section: one entry per report in the Reports left panel, and a full-pane screen for each.
Location: /app/reports/{report-name}/page.tsx
The Content Manager opens your app at /reports/{reportName}?appID=…, under a heading and a Reports › group › report breadcrumb.
Example:
"use client"
import { useAgilityAppSDK } from "@agility/app-sdk"
export default function UsageReport() {
const { initializing, report } = useAgilityAppSDK()
if (initializing) return null
// report: { name: "usage-by-day", appName: "my-library" }
return <div>Usage by day</div>
}
App Definition:
"reports": [
{
"name": "usage-by-day",
"label": "Usage by day",
"description": "Downloads per day.",
"permissions": [1],
"group": "My Library",
"order": 1
}
]
setTitle."Agility Reports" or "Custom Reports" joins that built-in group. Any other value makes a group with that heading, shared with other apps that use it. Without one, the report goes under your app's install name.report.appName is your app's name, lowercased with hyphens, and it's the same on every instance, so a report link copied from one instance works in another. Open one of your reports from any surface with navigate({ target: { type: "appReport", name: "usage-by-day" } }). From a report, an appItem opens on top of it, with parent set to { name: "appreport", id: "usage-by-day" }.
Flyouts are small popup interfaces that can be triggered from various locations in Agility CMS.
Location: /app/flyout/page.tsx
Example:
"use client"
import { useAgilityAppSDK, assetsMethods } from "@agility/app-sdk"
export default function Flyout() {
const { initializing, appInstallContext, instance, locale } = useAgilityAppSDK()
return (
<div>
<h1>Flyout</h1>
<div>Locale: {locale}</div>
<div>Instance: {JSON.stringify(instance)}</div>
</div>
)
}
New in 2.4.0. The rteToolbar surface adds buttons to the rich text editor toolbar. Each button opens your app in a dialog, where the author inserts or edits an embed that your app owns. The embed is stored as an element in the field's HTML.
There is no feature flag. The surface turns on in an instance as soon as an app that declares it is installed. App buttons appear after the editor's built-in buttons.
An app that uses the rteToolbar surface runs its own code inside the rich text editor, with the same access as the person editing. The Content Manager says so when an admin installs or configures it. Never put untrusted input into the markup your app returns.
Location: /app/rte-toolbar/{button-name}/page.tsx
The Content Manager opens your app at /rte-toolbar/{buttonName}?appID=…&closeModalID=…, so a button named flipcard needs a page at /rte-toolbar/flipcard.
Example:
"use client"
import { useState } from "react"
import { useAgilityAppSDK, resolveEmbed } from "@agility/app-sdk"
export default function FlipCardEmbed() {
const { initializing, embed } = useAgilityAppSDK()
const [front, setFront] = useState<string | null>(null)
if (initializing || !embed) return null
// embed.mode is "insert" for a new embed, "edit" when the author reopens one. front stays null until the author types, so Save keeps the saved value
const saved = embed.mode === "edit" ? (embed.value as any) : null
return (
<div>
<input defaultValue={saved?.front ?? ""} onChange={(e) => setFront(e.target.value)} />
<button onClick={() => resolveEmbed({ value: { front: front ?? saved?.front ?? "" } })}>Save</button>
<button onClick={() => resolveEmbed({ cancelled: true })}>Cancel</button>
</div>
)
}
Key Capabilities:
embed from useAgilityAppSDK():
buttonName: the button the author clickedmode: "insert" or "edit"value: the value you saved last time, when editingcontentItem: the content item the rich text field belongs toresolveEmbed({ value })resolveEmbed({ cancelled: true })Always call resolveEmbed. Without it, the dialog stays open.
App Definition:
"rteToolbar": [
{
"name": "flipcard",
"label": "Flip Card",
"description": "Insert a flip card",
"element": "acme-flipcard",
"editorComponentUrl": "/components/flipcard.js",
"icon": "embed",
"group": "Learning Objects"
}
]
data-agility-embed on the embeddiv. It must be a valid custom element name: lowercase, with a hyphen. Prefix it with your own name, such as acme-flipcard. If two apps declare the same tag, only the first keeps it, and the other app's button is skipped.element as a custom element, so the embed renders live in the editor. It must be a classic script (an IIFE or UMD bundle), not an ES module. It must be served from your app's own URL; other origins are refused.What the editor writes:
The value you pass to resolveEmbed({ value }) is stored as JSON in data-agility-value, and comes back as embed.value when the author reopens the embed.
<acme-flipcard class="agility-embed mceNonEditable"
data-agility-embed="flipcard"
data-agility-app="acme-flipcards"
data-agility-value="{"front":"Hello"}"></acme-flipcard>
data-agility-app is the slug of your app's name. Renaming a published app breaks the embeds already written with it.
Rendering embeds on your website:
The Fetch API returns the raw markup above. Your editorComponentUrl script only runs in the editor, so the website must render the element itself. For example, it can load its own component for the tag, or find each data-agility-embed element and read its data-agility-value.
Keep the editor version of your component simple. Make it non-interactive, because authors can't edit inside the embed in place. Avoid shadow DOM there too, because the editor's text selection doesn't work well with it. Your website's version of the component can use shadow DOM.
New in 2.5.0. A button that declares "insert": "link" inserts an ordinary <a> instead of an embed element, for buttons such as "attach a file" or "link to an issue". It needs no element or editorComponentUrl, and runs no code inside the editor.
"rteToolbar": [
{ "name": "attach-file", "label": "Attach File", "icon": "link", "insert": "link" }
]
The dialog opens at the same /rte-toolbar/{buttonName} route. The text the author had selected arrives as embed.selectionText, and when there is a selection, it becomes the link text. Resolve with insert instead of value:
resolveEmbed({
insert: {
kind: "link",
href: "https://files.example.com/f/6f1c",
text: "Quarterly report.pdf", // used only when nothing is selected
attributes: { "data-file-id": "6f1c" }
}
})
/, a #fragment, or an http://, https:// or mailto: URL. Anything else is refused and nothing is inserted.data-*, title, class, rel and target (_blank or _self) are kept. data-* attributes are saved and reach the Fetch API, so your website can find your links by them.The author edits the link afterwards with the editor's own link dialog.
useAgilityAppSDK()The primary hook for accessing SDK functionality. Returns different properties depending on the surface.
Common Properties:
const {
initializing, // Boolean: SDK initialization state
locale, // String: Current locale (e.g., "en-us")
appInstallContext, // Object: App installation context
instance // Object: Current instance information
} = useAgilityAppSDK()
Field-Specific Properties:
const {
field, // Object: Field definition
fieldValue, // Any: Current field value
contentItem // Object: Parent content item
} = useAgilityAppSDK()
Content Item Sidebar Properties:
const {
contentItem // Object: Current content item
} = useAgilityAppSDK()
Page Sidebar Properties:
const {
pageItem // Object: Current page item
} = useAgilityAppSDK()
Modal Properties:
const {
modalProps // Object: Props passed when opening the modal
} = useAgilityAppSDK()
Rich Text Editor Toolbar Properties:
const {
embed // Object: { buttonName, mode, value, contentItem } for the embed being inserted or edited
} = useAgilityAppSDK()
Section, Stack Item and Report Properties (2.5.0):
const {
section, // { name, panel? } on a section and its panel
item, // { name, id } on a stack item, and in a section panel
parent, // what the item opened on top of, or null
report // { name, appName } on a report
} = useAgilityAppSDK()
New in 2.5.0. Every surface, and useAgilityPreInstall(), gets the signed-in user and their permissions in this instance:
import { useAgilityAppSDK, hasPermission, Permission } from "@agility/app-sdk"
const { user } = useAgilityAppSDK()
// { userID, emailAddress, fullName, isOrgAdmin, permissions, teamIDs }
if (hasPermission(user, [Permission.Manage])) {
// show the Manage button
}
hasPermission(user, ids) applies the Content Manager's own rule: any of the listed IDs, with Full Control always passing, and false when there's no user.
Use user to decide what to show, never to decide what's allowed. It arrives in the browser, where the person using your app can change it. Your app's server must check every request itself, from the Management API token.
A role change shows the next time the surface loads.
useResizeHeight()Automatically adjusts the iframe height to fit content. Essential for fields and dashboards.
Usage:
const containerRef = useResizeHeight(10) // 10px padding
return <div ref={containerRef}>Content</div>
Parameters:
padding (optional): Additional padding in pixelsuseAgilityPreInstall()Used on the installation screen to handle app setup.
Usage:
import { setExtraConfigValues, useAgilityPreInstall } from "@agility/app-sdk"
export default function Install() {
const { initializing, appInstallContext, instance, locale } = useAgilityPreInstall()
return (
<div>
<h1>Install Screen</h1>
<button
onClick={() => {
setExtraConfigValues([{ name: "apiKey", value: "xyz123" }])
}}
>
Complete Install
</button>
</div>
)
}
Key Methods:
setExtraConfigValues(values): Set additional configuration values during installationimport { contentItemMethods } from "@agility/app-sdk"
// Get current content item
const item = await contentItemMethods.getContentItem()
// Save content item
await contentItemMethods.saveContentItem()
// Set field value (in custom fields)
contentItemMethods.setFieldValue({ value: "new value" })
// Listen to field changes
await contentItemMethods.addFieldListener({
fieldName: "Title",
onChange: (value) => console.log("Title changed:", value)
})
// Remove field listener
await contentItemMethods.removeFieldListener({
fieldName: "Title"
})
// Get selected items (in content list sidebar)
const selected = await contentItemMethods.getSelectedItems()
// Listen to selection changes (in content list sidebar)
contentItemMethods.addSelectedItemListener({
onChange: (items) => console.log("Selection changed:", items)
})
// Remove selection listener
contentItemMethods.removeSelectedItemListener()
import { pageMethods } from "@agility/app-sdk"
// Get current page item
const page = await pageMethods.getPageItem()
import { assetsMethods } from "@agility/app-sdk"
// Select assets
assetsMethods.selectAssets({
title: "Select Images",
singleSelectOnly: false, // Allow multiple selection
callback: (selectedAssets) => {
console.log("Selected:", selectedAssets)
// selectedAssets is an array of asset objects
}
})
Asset Object Structure:
{
mediaID: number,
url: string,
label: string,
fileSize: number,
// ... other properties
}
import { openModal, closeModal } from "@agility/app-sdk"
// Open a modal
openModal({
title: "My Modal",
name: "example-modal", // Must match modal name in app definition
props: {
// Custom data to pass to modal
userId: 123,
action: "edit"
},
callback: (result) => {
// Called when modal closes
console.log("Modal result:", result)
}
})
// Close modal (from within the modal)
closeModal({
success: true,
data: "some return value"
})
New in 2.4.0. These work on every surface. navigate takes the Content Manager to another screen. getNavigationUrl gets the address of that screen without going there, so you can render a real link.
import { navigate, getNavigationUrl } from "@agility/app-sdk"
// Open a content item on top of the current screen
const result = await navigate({ target: { type: "contentItem", contentID: 1866 } })
if (!result?.url) {
// The Content Manager did not navigate: fall back to what your app did before
}
// Get a link instead
const link = await getNavigationUrl({ target: { type: "newContentItem", containerID: 30 } })
const href = link?.url ?? null
| Target | Opens |
|---|---|
{ type: "contentItem", contentID, containerID? } | a content item |
{ type: "newContentItem", containerID } | a new item in that container |
{ type: "contentList", containerID } | a content list |
{ type: "page", pageID } | a page in the Pages section |
{ type: "appSection", name } | your app's section (2.5.0) |
{ type: "appItem", name, id, replace? } | one of your app's stack items (2.5.0) |
{ type: "appReport", name } | one of your app's reports (2.5.0) |
true): opens the destination on top of the current screen and keeps it in the breadcrumb. Set it to false to open a screen of its own; a contentItem target then needs containerID.{ url, path }. url is absolute, so use it in an <a href>.result?.url. On a Content Manager that doesn't support navigation, the promise resolves to an error message instead, so fall back when url is missing.navigate works, your app's frame is usually closed before the reply arrives. Awaiting it tells you that it failed, not that it worked.import { configMethods } from "@agility/app-sdk"
// Get a configuration value
const apiKey = configMethods.getConfigValue("apiKey")
import { refresh } from "@agility/app-sdk"
// Refresh the current list (in content list sidebar)
refresh()
If your app requires setup before installation, enable the installation screen.
Location: /app/install/page.tsx
App Definition:
"installScreen": true
Example:
import { setExtraConfigValues, useAgilityPreInstall } from "@agility/app-sdk"
export default function Install() {
const { initializing, appInstallContext } = useAgilityPreInstall()
return (
<div>
<h1>Setup Instructions</h1>
<p>Configure your app settings...</p>
<button
onClick={() => {
setExtraConfigValues([
{ name: "setting1", value: "value1" },
{ name: "setting2", value: "value2" }
])
}}
>
Complete Installation
</button>
</div>
)
}
Define configuration values in your app definition that can be set during installation.
"configValues": [
{
"label": "API Key",
"name": "apiKey",
"type": "string"
},
{
"label": "Enable Feature",
"name": "enableFeature",
"type": "boolean"
}
]
Accessing Configuration:
const { appInstallContext } = useAgilityAppSDK()
const apiKey = appInstallContext?.configuration?.apiKey
Agility doesn't notify your app when it's uninstalled. Uninstalling removes the app from the instance and its surfaces stop loading, but no request is sent to your app's server. An uninstallHook in the app definition has no effect.
If your app keeps data, tokens or webhooks for an instance, don't rely on uninstall to clean them up:
Apps can integrate with external services using OAuth.
App Definition:
"connections": [
{
"name": "Agility API Offline Access",
"icon": "https://cdn.aglty.io/logo.svg",
"url": "/oauth/agility-api-offline"
}
]
OAuth Flow (/app/oauth/agility-api-offline/page.tsx):
"use client"
import { useEffect } from "react"
export default function AgilityAPI() {
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const code = params.get("code")
const redirect_uri = params.get("redirect_uri")
if (code) {
// Exchange code for token
const formData = new FormData()
formData.append("code", code)
fetch("https://mgmt.aglty.io/oauth/token", {
method: "POST",
body: formData
})
.then((res) => res.text())
.then((token) => {
// Redirect back with token
window.location.href = `${redirect_uri}#${encodeURIComponent(token)}`
})
} else {
// Start OAuth flow
const authUrl = `https://mgmt.aglty.io/oauth/authorize?response_type=code&redirect_uri=${encodeURIComponent(
window.location.href
)}&scope=offline_access`
window.location.href = authUrl
}
}, [])
return <div>Authenticating...</div>
}
The SDK provides comprehensive TypeScript types:
import type { IContentItem, IPageItem, IAppConfigValue, IAppInstallContext, IField, IAsset } from "@agility/app-sdk"
import type { IEmbedContext, INavigationTarget, INavigationUrl } from "@agility/app-sdk"
import type { IUser, ISectionContext, IStackItemContext, IReportContext } from "@agility/app-sdk"
initializing: Wait for SDK initialization before renderingif (initializing) return <div>Loading...</div>
useResizeHeight() for fields and dashboards: Ensure proper iframe sizingconst ref = useResizeHeight()
return <div ref={ref}>...</div>
useEffect(() => {
return () => {
contentItemMethods.removeFieldListener({ fieldName: "Title" })
}
}, [])
try {
await contentItemMethods.saveContentItem()
} catch (error) {
console.error("Failed to save:", error)
}
const [item, setItem] = useState<IContentItem | null>(null)
Here's a complete custom field example demonstrating multiple SDK features:
"use client"
import { useAgilityAppSDK, contentItemMethods, useResizeHeight, assetsMethods, openModal } from "@agility/app-sdk"
import { useEffect, useState } from "react"
export default function AdvancedField() {
const { initializing, fieldValue, contentItem } = useAgilityAppSDK()
const containerRef = useResizeHeight(10)
const [relatedTitle, setRelatedTitle] = useState("")
// Listen to another field
useEffect(() => {
contentItemMethods.addFieldListener({
fieldName: "Title",
onChange: (value) => setRelatedTitle(value as string)
})
return () => {
contentItemMethods.removeFieldListener({ fieldName: "Title" })
}
}, [])
if (initializing) return null
const handleSelectAsset = () => {
assetsMethods.selectAssets({
title: "Select Image",
singleSelectOnly: true,
callback: (assets) => {
if (assets.length > 0) {
contentItemMethods.setFieldValue({ value: assets[0].url })
}
}
})
}
const handleOpenModal = () => {
openModal({
title: "Configure Field",
name: "field-config-modal",
props: { currentValue: fieldValue },
callback: (result) => {
if (result?.value) {
contentItemMethods.setFieldValue({ value: result.value })
}
}
})
}
return (
<div ref={containerRef} className="p-4">
<div className="mb-2">
<label>Current Value:</label>
<input
value={(fieldValue as string) || ""}
onChange={(e) => contentItemMethods.setFieldValue({ value: e.target.value })}
className="w-full rounded border p-2"
/>
</div>
<div className="mb-2">
<p>Related Title: {relatedTitle}</p>
</div>
<div className="flex gap-2">
<button onClick={handleSelectAsset} className="btn">
Select Asset
</button>
<button onClick={handleOpenModal} className="btn">
Open Modal
</button>
</div>
</div>
)
}