Overview
How to Create Apps in Agility
Build custom functionality that runs inside Agility CMS using the Apps SDK v2. This guide covers the end‑to‑end flow: planning your app, defining capabilities, implementing UI surfaces, local development, OAuth, installation, and best practices.
You only implement the surfaces you need. An app can be a single custom field, a sidebar, a dashboard, one or more modals, or a combination.
This guide is hosted at http://agilitycms.com/docs/apps/creating-apps-for-agility. For related topics, see:
We provide a complete “kitchen sink” example you can run and use as a reference:
npm run dev, npm run build, npm start/.well-known/agility-app.json (ensure CORS is enabled in your framework/server)app/ folder. If you’re using a different React setup (Vite/CRA), map each surface to a routable React component path your router can render.npm install @agility/app-sdk
Optional (for management API scenarios):
npm install @agility/management-sdk
Every app must expose /.well-known/agility-app.json. This file describes your app’s metadata, configuration options, OAuth connections, and the surfaces it provides.
Minimal example (only a custom field):
{
"name": "My Custom Field",
"version": "1.0.0",
"__sdkVersion": "2.0.0",
"capabilities": {
"fields": [{ "name": "my-field", "label": "My Field", "description": "Custom editor UI." }]
}
}
Kitchen‑sink example tips:
public/.well-known/agility-app.json (or serve it via middleware)headers() or your server config)Your app is a React application. Each surface should be a routable React component/page that the Agility CMS UI can load in an iframe.
If you use Next.js, each surface is a route under app/ (App Router):
app/fields/<field-name>/page.tsxapp/content-list-sidebar/page.tsxapp/content-item-sidebar/page.tsxapp/page-sidebar/page.tsxapp/home-dashboard/page.tsx, app/content-dashboard/page.tsx, app/pages-dashboard/page.tsxapp/modals/<modal-name>/page.tsxapp/install/page.tsxapp/api/app-uninstall/route.ts (or an API endpoint in your chosen framework)app/oauth/<connection>/page.tsxIf you use Vite/CRA or a custom React setup:
/fields/my-field, /modals/example-modal)./.well-known/agility-app.json from your static assets or an express middleware.// server.js (optional express for local dev)
import express from "express"
import fs from "fs"
import path from "path"
const app = express()
app.get("/.well-known/agility-app.json", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*")
const json = fs.readFileSync(path.join(process.cwd(), "public/.well-known/agility-app.json"), "utf-8")
res.type("application/json").send(json)
})
app.use(express.static("dist"))
app.listen(3001, () => console.log("App running on http://localhost:3001"))
/.well-known/agility-app.json and each surface route.In each surface, call useAgilityAppSDK() to access context and SDK methods. Common properties:
initializing: boolean initialization state (render nothing until false)locale: current locale codeappInstallContext: install details including configurationinstance: current instance metadataAdditional properties per surface:
field, fieldValue, contentItemcontentItempageItemmodalPropsBelow are minimal React starters you can copy and expand. Examples use Next.js files, but the components work the same in any React router—adjust file paths accordingly.
File: app/fields/example-field/page.tsx
"use client"
import { useAgilityAppSDK, contentItemMethods, useResizeHeight } from "@agility/app-sdk"
export default function ExampleField() {
const { initializing, fieldValue } = useAgilityAppSDK()
const containerRef = useResizeHeight(10)
if (initializing) return null
return (
<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>
)
}
Add to app definition:
{
"capabilities": {
"fields": [{ "name": "example-field", "label": "Example Field", "description": "Custom UI." }]
}
}
File: app/content-item-sidebar/page.tsx
"use client"
import { useAgilityAppSDK, contentItemMethods } from "@agility/app-sdk"
import { useEffect, useState } from "react"
export default function ContentItemSidebar() {
const { initializing, contentItem } = useAgilityAppSDK()
const [heading, setHeading] = useState<string>("")
useEffect(() => {
if (!contentItem?.values?.Heading) return
setHeading(contentItem.values.Heading as string)
}, [contentItem])
if (initializing) return null
return (
<div className="p-3">
<p>Heading: {heading}</p>
<button onClick={() => contentItemMethods.saveContentItem()}>Save</button>
<button
onClick={() =>
contentItemMethods.addFieldListener({
fieldName: "Heading",
onChange: (val) => setHeading(val as string)
})
}
>
Listen to Heading
</button>
</div>
)
}
Add to app definition:
{
"capabilities": {
"contentItemSidebar": { "description": "Helper tools for content editing." }
}
}
File: app/page-sidebar/page.tsx
"use client"
import { useAgilityAppSDK, pageMethods } from "@agility/app-sdk"
import { useState } from "react"
export default function PageSidebar() {
const { initializing } = useAgilityAppSDK()
const [page, setPage] = useState<any>()
if (initializing) return null
return (
<div>
<button onClick={async () => setPage(await pageMethods.getPageItem())}>Get Page</button>
<pre className="whitespace-pre-wrap break-words text-xs">{JSON.stringify(page, null, 2)}</pre>
</div>
)
}
Files: app/home-dashboard/page.tsx, app/content-dashboard/page.tsx, app/pages-dashboard/page.tsx
"use client"
import { useAgilityAppSDK, useResizeHeight, assetsMethods } from "@agility/app-sdk"
export default function Dashboard() {
const { initializing, locale, appInstallContext } = useAgilityAppSDK()
const ref = useResizeHeight()
if (initializing) return null
return (
<div ref={ref} className="p-4">
<h1 className="text-xl font-semibold">Dashboard</h1>
<div>Locale: {locale}</div>
<div>Config: {JSON.stringify(appInstallContext?.configuration)}</div>
<button
onClick={() =>
assetsMethods.selectAssets({
title: "Select Assets",
singleSelectOnly: false,
callback: console.log
})
}
>
Select Assets
</button>
</div>
)
}
Capabilities (choose which):
{
"capabilities": {
"homeDashboard": { "description": "Instance-level analytics." },
"contentDashboard": { "description": "Content insights." },
"pagesDashboard": { "description": "Pages insights." }
}
}
File: app/modals/example-modal/page.tsx
"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 gap-3 p-3">
<h2 className="text-lg font-semibold">Example Modal</h2>
<div className="flex-1">Props: {JSON.stringify(modalProps)}</div>
<div className="flex gap-2">
<button onClick={() => closeModal({ btn: "ok" })}>OK</button>
<button onClick={() => closeModal({ btn: "cancel" })}>Cancel</button>
</div>
</div>
)
}
Open from any surface:
import { openModal } from "@agility/app-sdk"
openModal({
title: "Example Modal",
name: "example-modal",
props: { foo: "bar" },
callback: (result) => console.log("Modal result:", result)
})
Add to app definition:
{
"capabilities": {
"modals": [{ "name": "example-modal", "label": "Example Modal", "description": "Reusable dialog." }]
}
}
Add a pre‑install screen to collect extra configuration values.
File: app/install/page.tsx
"use client"
import { setExtraConfigValues, useAgilityPreInstall } from "@agility/app-sdk"
export default function Install() {
const { initializing } = useAgilityPreInstall()
if (initializing) return null
return (
<div>
<h1>Install</h1>
<button onClick={() => setExtraConfigValues([{ name: "apiKey", value: "xyz123" }])}>
Complete Install
</button>
</div>
)
}
Enable in app definition:
{ "capabilities": { "installScreen": true } }
Declare OAuth connections in the app definition and implement a matching route.
App definition snippet:
{
"connections": [
{
"name": "Agility API Offline Access",
"icon": "https://cdn.aglty.io/content-manager/images/logo-triangle-only-yellow.svg",
"url": "/oauth/agility-api-offline"
}
]
}
Route: 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 && redirect_uri) {
const formData = new FormData()
formData.append("code", code)
fetch("https://mgmt.aglty.io/oauth/token", { method: "POST", body: formData })
.then((r) => r.text())
.then((token) => (window.location.href = `${redirect_uri}#${encodeURIComponent(token)}`))
} else {
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>
}
Handle cleanup when an app is uninstalled.
File: app/api/app-uninstall/route.ts
export async function POST(request: Request) {
const body = await request.json()
// Clean up resources, stored data, and tokens
return new Response("OK", { status: 200 })
}
Enable in app definition:
{ "capabilities": { "uninstallHook": "/api/app-uninstall" } }
npm install
npm run dev
http://localhost:3001/.well-known/agility-app.json./.well-known/agility-app.json.initializing === false before rendering UI.useResizeHeight() in fields and dashboards for automatic iframe sizing.contentItemMethods.setFieldValue() and that the field name matches the model.name matches the definition and the route exists under app/modals/<name>/page.tsx./.well-known/agility-app.json (see next.config.js)./.well-known/agility-app.json and list only the surfaces you implement.useAgilityAppSDK(), and prefer useResizeHeight() where embedded.