Block Development Guide
How to create custom blocks for glokki using @glokki-cms/sdk.
This guide covers defining Zod schemas, implementing React components, registering blocks with defineBlock(), and testing them in the visual editor. For initial project setup and integration, see the Integration Guide first.
1. Block Anatomy
Every block consists of three parts:
- Zod schema — declares the editable props, their types, defaults, and labels
- React component — renders the block given its props
defineBlock()call — bundles schema + component + metadata into a registry entry
The CMS never imports your Zod schema directly. Instead, defineBlock() serialises it into FieldDescriptor[] — a portable JSON format — and sends it to the CMS via the iframe handshake. The CMS builds its PropsPanel entirely from these descriptors.
┌──────────────────┐ defineBlock() ┌────────────────────┐
│ Zod Schema │ ──────────────────▶ │ FieldDescriptor[] │
│ + React Component │ + Component ref │
└──────────────────┘ └────────┬───────────┘
│
createBlockRegistry()
│
▼
┌────────────────────┐
│ BlockRegistry │
│ (iframe handshake) │
└────────────────────┘
2. Step-by-Step: Create a Block
This walkthrough creates a Testimonial block from scratch.
File Structure
blocks/
├── testimonial.tsx ← schema + component + defineBlock()
├── registry.ts ← createBlockRegistry() with all blocks
└── editor-shell.tsx ← VisualEditorProvider wrapper
One file per block, co-located with the registry. Export the defineBlock() result and optionally the component for standalone use.
3. Define the Zod Schema
The schema is a z.object() where each key becomes an editable field in the CMS PropsPanel. Zod methods control how the field appears:
import { z } from "zod"
const testimonialSchema = z.object({
quote: z
.string()
.min(1)
.max(500)
.default("This product changed everything for us.")
.meta({ fieldType: "textarea" })
.describe("Quote"),
author: z
.string()
.min(1)
.max(100)
.default("Jane Doe")
.describe("Author Name"),
role: z.string().max(100).optional().describe("Author Role"),
avatar: z
.string()
.url()
.optional()
.meta({ fieldType: "image" })
.describe("Avatar"),
rating: z.number().min(1).max(5).default(5).describe("Rating"),
highlighted: z.boolean().default(false).describe("Highlighted"),
style: z.enum(["card", "minimal", "quote"]).default("card").describe("Style"),
accentColor: z
.string()
.default("#3b82f6")
.meta({ fieldType: "color" })
.describe("Accent Color"),
})
Key Zod Methods
| Method | Purpose | Example |
|---|---|---|
.describe("Label") | Sets the field label in the PropsPanel | .describe("Author Name") |
.meta({ fieldType: "..." }) | Overrides the default field control | .meta({ fieldType: "image" }) |
.meta({ placeholder: "..." }) | Sets placeholder text | .meta({ placeholder: "Enter a quote..." }) |
.meta({ helpText: "..." }) | Adds help text below the control | .meta({ helpText: "Shown below the quote" }) |
.meta({ hidden: true }) | Hides the field from the editor | .meta({ hidden: true }) |
.default(value) | Sets the default value and makes the field optional | .default("Hello") |
.optional() | Makes the field optional (no default value) | .optional() |
.min(n) / .max(n) | String length or numeric range bounds | .min(1).max(500) |
.url() | Marks a string as a URL field | .url() |
.regex(pattern) | Adds a regex validation constraint | .regex(/^#[0-9a-f]{6}$/i) |
Rules
- The root schema must be
z.object()—z.record(),z.string(), etc. are not supported. - Every field should have
.describe("Label")— without it, the label is auto-generated from the key name (e.g.,authorNamebecomes "Author Name"). - Use
.default()to declare the fallback value for schema validation and field metadata. Note:.default()alone does not set initial values when adding a block — that's whatdefaultPropsindefineBlock()does. Always set both. - Use
.optional()for truly optional fields with no sensible default. - Fields must be flat: scalars and arrays only, no nested
z.object()field, and no wrapper other than.default()/.optional()— see Fields Must Be Flat.
4. Schema-to-Editor Field Mapping
The SDK serialises Zod schemas into FieldDescriptor[]. Each Zod type maps to a specific editor control:
| Zod Type | Editor Control | Notes |
|---|---|---|
z.string() | Text input | Single-line text field |
z.string() + .meta({ fieldType: "textarea" }) | Textarea | Multi-line text |
z.string() + .meta({ fieldType: "richtext" }) | Rich text editor | HTML/Markdown editor |
z.string().url() | URL input | With URL validation |
z.string() + .meta({ fieldType: "image" }) | Image picker | Upload or URL input |
z.string() + .meta({ fieldType: "color" }) | Color picker | Hex color selector |
z.number() | Number input | With optional min/max |
z.boolean() | Toggle switch ("switch") | On/off toggle |
z.enum([...]) | Select dropdown | Options from enum values |
z.array(z.object({...})) | Repeatable list ("array") | Add/remove/reorder items (up/down buttons) |
z.array(z.object({...})) + .meta({ fieldType: "repeater" }) | Repeater ("repeater") | Add/remove rows, drag-and-drop reorder |
anything else (nested z.object(), z.union(), a .catch()/.nullable() wrapper, …) | Text input ("text") | Almost never what you want — see Fields Must Be Flat |
Rendering uploaded images: any image field — by any name, scalar or array, nested at any depth — is rewritten by the Delivery API to a
/cdn/URL with?w&h&fpquery params. Render it with a small image component of your own that builds a responsivesrcSetby re-stamping?wand applies?fpas CSSobject-position. See Asset CDN Proxy.
How Validation Maps
Zod validation methods are extracted and sent to the CMS for client-side validation:
// String validation
z.string().min(1).max(120)
// → { validation: { minLength: 1, maxLength: 120 } }
// Number validation
z.number().min(0).max(100)
// → { validation: { min: 0, max: 100 } }
// Regex validation
z.string().regex(/^[A-Z]/)
// → { validation: { pattern: "^[A-Z]" } }
How Defaults and Optionality Map
// Required field (no default, not optional)
z.string().min(1).describe("Title")
// → { required: true }
// Optional with default
z.string().default("Hello").describe("Greeting")
// → { required: false, defaultValue: "Hello" }
// Optional without default
z.string().optional().describe("Subtitle")
// → { required: false }
Fields Must Be Flat
Every field of a block schema must be a scalar (z.string(), z.number(), z.boolean(), z.enum([...])) or an array (z.array(...), optionally marked repeater). A plain nested object — cta: z.object({ label, href }) — is not supported as a field, at any level (a field inside an array row included).
The reason is the serialisation step above: the CMS never sees your Zod schema, only the FieldDescriptor[] built from it, and there is no "object" field type. Any field the serializer doesn't recognise becomes a plain "text" field. A nested object therefore arrives as a single text field: the PropsPanel shows a text input, the CMS validates the value as a string (so the publish check, validate_page and the MCP write tools reject the object you meant to store), and its sub-fields can't be addressed individually.
Split the object into top-level fields instead, and use an array of objects for anything that's a list:
// ❌ Reaches the CMS as one "text" field
cta: z.object({ label: z.string(), href: z.string() }).default({ label: "", href: "" }),
// ✅ Flat
ctaLabel: z.string().default("Learn more").describe("CTA label"),
ctaHref: z.string().default("/").describe("CTA link"),
Wrappers. The serializer looks through exactly two wrappers to find a field's type: .default(...) and .optional() (plus .describe(), .meta() and validation checks such as .min(), .max(), .regex(), .url()). Any other wrapper hides the type, and the field arrives as "text" even when the inner type is a scalar:
| Field | Arrives in the CMS as | Consequence |
|---|---|---|
z.boolean().default(false) | switch | ✅ toggle |
z.boolean().catch(false) | text, required | text input; true/false are rejected as "expected string" |
z.number().nullable().optional() | text | numbers are rejected as "expected string" |
z.string().nullable() | text, required | null is rejected |
z.string().meta({ fieldType: "image" }).catch("") | text, required | the image picker (or textarea, richtext, color) is lost |
z.array(...).catch([]) | text | the list editor is lost entirely |
So don't use .catch(), .nullable() / .nullish(), .transform(), z.preprocess() or z.union() on fields. Cover missing data with .default(...), and tolerate unexpected values in the component rather than in the schema. A unit test on YourBlock.fields (see Unit Testing Blocks) catches a field that serialises to the wrong fieldType.
5. Implement the React Component
The component receives the schema's inferred type as props. Use z.infer<typeof schema> for type safety:
type TestimonialProps = z.infer<typeof testimonialSchema>
function TestimonialComponent({
quote = "This product changed everything for us.",
author = "Jane Doe",
role,
avatar,
rating = 5,
highlighted = false,
style = "card",
accentColor = "#3b82f6",
}: TestimonialProps) {
return (
<section
className={`px-6 py-8 sm:px-8 sm:py-12 ${
style === "card"
? "mx-auto max-w-lg rounded-xl border bg-card p-6 shadow-sm"
: style === "quote"
? "border-l-4 pl-6"
: ""
} ${highlighted ? "outline outline-2 outline-offset-2" : ""}`}
style={{
...(style === "quote" ? { borderColor: accentColor } : {}),
...(highlighted ? { outlineColor: accentColor } : {}),
}}
>
<blockquote className="text-base leading-relaxed italic sm:text-lg">
“{quote}”
</blockquote>
<div className="mt-4 flex items-center gap-3">
{avatar && (
<img
src={avatar}
alt={author}
className="h-10 w-10 rounded-full object-cover"
/>
)}
<div>
<p className="font-semibold">{author}</p>
{role && <p className="text-sm text-muted-foreground">{role}</p>}
</div>
</div>
{rating > 0 && (
<div
className="mt-3 flex gap-0.5"
aria-label={`${rating} out of 5 stars`}
>
{Array.from({ length: 5 }, (_, i) => (
<span
key={i}
className={`text-lg ${i < rating ? "opacity-100" : "opacity-20"}`}
style={i < rating ? { color: accentColor } : undefined}
>
★
</span>
))}
</div>
)}
</section>
)
}
Component Guidelines
- Set default values in the function signature matching
defaultProps— the component may render before the CMS sends initial props. - Handle optional fields gracefully — check for
undefinedbefore rendering optional elements. - Use Tailwind CSS for styling — the CMS iframe inherits the tenant app's styles.
- Keep components pure — no side effects, no data fetching, no hooks beyond basic React state.
- Avoid external state — each block instance should be self-contained.
6. Register with defineBlock()
Bundle the schema, component, and metadata into a BlockRegistryEntry:
import { defineBlock } from "@glokki-cms/sdk"
export const TestimonialBlock = defineBlock({
type: "testimonial",
label: "Testimonial",
category: "content",
icon: "Quote",
description: "Customer testimonial with rating and avatar",
defaultProps: {
quote: "This product changed everything for us.",
author: "Jane Doe",
rating: 5,
highlighted: false,
style: "card",
accentColor: "#3b82f6",
},
propsSchema: testimonialSchema,
component: TestimonialComponent,
})
defineBlock() Config
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | Unique block type key (e.g., "testimonial") |
label | string | Yes | Display name in the Component Library |
category | string | Yes | Category key for grouping — free-form, 1–64 characters, no whitespace. Must appear in the registry's categories list when one is declared; otherwise use a CMS built-in (content, layout, media, interactive, list) |
icon | string | Yes | Lucide icon name (e.g., "Quote", "Image", "Sparkles") |
description | string | No | Short description shown in the Component Library |
defaultProps | InferOutput<TSchema> | Yes | Default values when adding a new instance |
propsSchema | z.object({...}) | Yes | Zod object schema |
component | ComponentType | Yes | React component |
viewportDefaults | { tablet?: Partial<Props>, mobile?: Partial<Props> } | No | Per-viewport prop overrides |
slots | SlotDefinition[] | (props) => SlotDefinition[] | No | Named areas for child blocks |
summary | { field: string, noun: string } | No | Count summary shown on the editor's collapsed block card |
Block card summary
summary adds a short at-a-glance count to the block's card in the editor's
Blocks list — e.g. a slider showing "2 slides" instead of just "Slider". It
counts the length of an array prop and pluralises the noun:
export const SliderBlock = defineBlock({
type: "slider",
label: "Slider",
// …
propsSchema: z.object({ slides: z.array(slideSchema) }),
defaultProps: { slides: [] },
summary: { field: "slides", noun: "slide" }, // → "0 slides", "1 slide", "2 slides"
component: Slider,
})
It must be a plain serialisable object ({ field, noun }), not a function —
block definitions cross the CMS handshake as JSON, so a callback could not be
sent. The CMS reads props[field].length and renders N noun(s); nothing shows
when summary is omitted or the field isn't an array.
defaultProps vs .default()
defaultProps sets the initial values when the block is added from the Component Library. Zod .default() values are used for schema validation and field metadata. They should match for consistency:
// In the schema:
heading: z.string().default("Welcome").describe("Heading"),
// In defineBlock():
defaultProps: {
heading: "Welcome", // ← matches the schema .default()
}
Markdown for agents (optional)
defineBlock() also accepts markdown: (props, ctx) => string | null (or
markdown: false) to control how the block appears when the page is served as
Markdown to LLM agents. Without it the block falls back to its text fields.
See the Markdown for agents guide.
7. Add to the Block Registry
Import the new block into your registry file and add it to createBlockRegistry():
// blocks/registry.ts
import { createBlockRegistry } from "@glokki-cms/sdk"
import type { ComponentType } from "react"
import { HeroBlock } from "./hero"
import { TestimonialBlock } from "./testimonial"
export const blockRegistry = createBlockRegistry([
HeroBlock,
TestimonialBlock,
// ... other blocks
])
export const registry: Map<
string,
ComponentType<Record<string, unknown>>
> = new Map(
blockRegistry
.getTypes()
.map((type) => [
type,
blockRegistry.getComponent(type) as ComponentType<
Record<string, unknown>
>,
])
)
The blockRegistry is passed to <VisualEditorProvider> for the v2 handshake. The registry Map is used by <CMSPageEditor> to resolve components by type.
createBlockRegistry() throws if duplicate type values are detected.
Grouping blocks under your own categories
category is a free-form key, so the picker's groups are yours to name. Pass an
ordered categories list as createBlockRegistry()'s second argument:
export const blockRegistry = createBlockRegistry(
[HeroBlock, TestimonialBlock],
{
categories: [
{ key: "parish", label: "Parish life" },
{ key: "content", label: "Content" },
],
}
)
The declared list replaces the CMS's built-in categories (content,
layout, media, interactive, list) rather than merging with them, and the
picker renders them in the array's order. Keys and labels must be non-empty and
at most 64 characters, keys may not contain whitespace, and every block's
category must appear in the list — createBlockRegistry() throws at build
time otherwise. blockRegistry.getCategories() returns the declared list, or
undefined when you declared none; it reaches the CMS on the same handshake as
the block definitions, so it needs @glokki-cms/react 0.9.0 or later.
Order is an editorial decision, not just a sort
The picker opens categories from the top until it has revealed twelve cards, and always opens the first one however large it is. So whichever category you list first is the one your editors see expanded on every insert, and the rest start collapsed behind a one-line preview of their contents.
Put the category editors reach for most often first. A first category holding more than a dozen blocks fills the budget on its own and leaves every other category collapsed — which is the right shape for a large registry, and the wrong one if your most-used blocks are spread across two small groups.
Migrating a registry that already uses the CMS categories
Start from what your blocks actually declare today:
grep -rhoE 'category: "[^"]+"' --include="*.tsx" --include="*.ts" . \
| grep -v node_modules | sort | uniq -c | sort -rn
Then work in one commit, because the build fails in between: add the
categories list, change each block's category to its new key, and keep any
built-in key you still want by declaring it explicitly. The build-time throw is
your migration checklist — it names the first block whose category is not in
the list, one at a time, until the registry is consistent.
Verifying it actually reached the CMS
The categories only reach the CMS through the editor handshake, so a green build and passing unit tests do not prove the feature works — they prove the registry is well-formed. Open a page in the CMS editor against your running app and check the Add block modal for three things:
- your labels, not the CMS's five;
- your order;
- no header showing a raw key — a heading like
parishwhere you expected "Parish life" means that block'scategoryis not in the declared list, so the picker fell back to grouping it under the raw key.
If the picker still shows the built-in categories, diagnose from the producer
inward: is @glokki-cms/react on 0.9.0 (an older one silently sends no
categories at all, with no error anywhere), does the handshake complete at all
(check the browser console), and did createBlockRegistry() actually receive a
second argument.
8. Advanced Patterns
Array Fields (Repeatable Items)
Use z.array(z.object({...})) for lists of structured items — this is the one place an object belongs in a block schema. Each row object's fields become sub-fields in the PropsPanel, and the row's own fields follow the same flat-fields rule (scalars and arrays only). Add .meta({ fieldType: "repeater" }) to get the repeater control, with drag-and-drop row reordering:
const faqItemSchema = z.object({
question: z.string().min(1).max(200).describe("Question"),
answer: z
.string()
.min(1)
.max(1000)
.meta({ fieldType: "textarea" })
.describe("Answer"),
})
const faqSchema = z.object({
heading: z.string().max(120).optional().describe("Section Heading"),
items: z
.array(faqItemSchema)
.default([
{ question: "What is this?", answer: "A frequently asked question." },
])
.describe("FAQ Items"),
})
The editor renders array fields with add/remove/reorder controls, and each item expands to show its sub-fields.
Viewport-Responsive Defaults
Use viewportDefaults to set different prop values per breakpoint:
export const BannerBlock = defineBlock({
type: "banner",
label: "Banner",
category: "content",
icon: "Megaphone",
defaultProps: {
heading: "Announcement",
columns: 3,
showImage: true,
},
propsSchema: bannerSchema,
component: BannerComponent,
viewportDefaults: {
tablet: { columns: 2 },
mobile: { columns: 1, showImage: false },
},
})
Viewport defaults are serialised into the block definition and sent to the CMS during the handshake. The editor can use these to suggest different prop values per breakpoint.
Slots (Container Blocks)
Slots let a block contain child blocks in named areas:
export const TwoColumnBlock = defineBlock({
type: "two-column",
label: "Two Columns",
category: "layout",
icon: "Columns2",
defaultProps: {},
propsSchema: z.object({}),
component: TwoColumnComponent,
slots: [
{ name: "left", label: "Left Column" },
{ name: "right", label: "Right Column" },
],
})
For dynamic slots (where the number depends on props), pass a function:
slots: (props) =>
Array.from({ length: props.columns }, (_, i) => ({
name: `column-${i}`,
label: `Column ${i + 1}`,
})),
Slots can restrict which block types are allowed via allowedTypes:
slots: [
{
name: "content",
label: "Content Area",
allowedTypes: ["rich-text", "image", "cta"],
},
],
Hidden Fields
Use .meta({ hidden: true }) for computed or internal fields that shouldn't appear in the editor:
srcSet: z
.string()
.optional()
.meta({ hidden: true })
.describe("Computed srcset"),
9. Testing Blocks Locally
Run the CMS and Tenant App Together
-
Start your tenant app:
cd /path/to/your-app bun dev # runs on http://localhost:3001 -
Point the CMS at it:
- Go to Dashboard → Settings → Visual Editor
- Set Frontend URL to
http://localhost:3001
The editor iframe runs in your browser, so the hosted CMS can load an app on
localhost. The setting applies to the whole tenant, though: everyone else editing that tenant now gets your local app too. Use a separate development tenant, or set the URL back to your deployed app when you are done. -
Open a page in the visual editor — your tenant app loads in an iframe inside the CMS. The handshake sends your block definitions to the CMS, which builds the Component Library and PropsPanel dynamically.
Verify Block Registration
Open a page in the visual editor. If the handshake succeeds, your new block should appear in the Component Library panel. You can also inspect postMessage traffic in the browser DevTools (Application → Frames or Console → filter by handshake) to confirm the handshake completed.
If your block doesn't appear in the Component Library:
- Check the console for errors in
defineBlock()(e.g., non-z.object()schema) - Verify the block is included in
createBlockRegistry() - Confirm the registry is passed to
<VisualEditorProvider blockRegistry={...}>
After Every Deploy: Open the Visual Editor Once
The CMS keeps a snapshot of your block registry (and your layout registry) for everything that runs outside the browser — the publish check, validate_page, and the MCP tools. That snapshot is refreshed only when someone opens a page in the visual editor: every successful editor handshake re-sends the registry. Deploying your app does not refresh it, and an MCP client or API call cannot trigger the refresh itself.
So after every deploy that adds a block, adds or changes a field, or changes a block's slots, open any page in the visual editor once against the deployed app (the editor loads whatever your Frontend URL setting points at, so the snapshot reflects that deployment). Until you do, the server side still works from the old snapshot:
- the MCP
whoamitool still reports the oldblockCountandschemaSyncedAt, andlist_block_types/get_block_schemadon't know the new block (BLOCK_TYPE_NOT_FOUND); add_blockwith the new type is refused withBLOCK_TYPE_NOT_FOUND;- publishing a page that contains the new block fails with
BLOCK_VALIDATION_FAILED("Unknown block type … not in tenant registry"), and a field whose type changed is still validated against its old type; - a tenant that has never opened the editor has no snapshot at all — publishing and the MCP block tools refuse with
REGISTRY_NOT_SYNCED.
A brand-new prop on an existing block is the one case that isn't refused: props the snapshot doesn't know about are stored without validation until the next sync.
Troubleshooting Editor Mode
| Symptom | Cause | Fix |
|---|---|---|
| "Unknown Block" placeholder | Block type not in registry | Add to createBlockRegistry() and component Map |
| Block renders but props panel is empty | Schema not serialised | Ensure propsSchema is a z.object() |
| Field label shows camelCase key | Missing .describe() | Add .describe("Human Label") to the field |
| Field shows as plain text input | Missing .meta() override | Add .meta({ fieldType: "richtext" }) etc. |
Field shows as plain text input despite .meta() / is a boolean or number | Nested z.object() field, or a .catch() / .nullable() / z.preprocess() wrapper | Flatten the field; wrap only with .default() / .optional() (Fields Must Be Flat) |
| New block missing from MCP / publish says "Unknown block type" after a deploy | The CMS's registry snapshot predates the deploy | Open any page in the visual editor once |
| Iframe blocked | CSP misconfiguration | Set frame-ancestors 'self' https://your-cms.com |
| Handshake timeout | Origin mismatch | Check frontendUrl matches your dev URL |
Unit Testing Blocks
Use Vitest to test block schemas and components:
// blocks/testimonial.test.tsx
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { TestimonialBlock } from "./testimonial"
describe("TestimonialBlock", () => {
it("has correct metadata", () => {
expect(TestimonialBlock.type).toBe("testimonial")
expect(TestimonialBlock.label).toBe("Testimonial")
expect(TestimonialBlock.category).toBe("content")
})
it("serialises schema to field descriptors", () => {
const fieldNames = TestimonialBlock.fields.map((f) => f.name)
expect(fieldNames).toContain("quote")
expect(fieldNames).toContain("author")
expect(fieldNames).toContain("rating")
})
it("maps field types correctly", () => {
const quoteField = TestimonialBlock.fields.find((f) => f.name === "quote")
expect(quoteField?.fieldType).toBe("textarea")
const avatarField = TestimonialBlock.fields.find((f) => f.name === "avatar")
expect(avatarField?.fieldType).toBe("image")
const ratingField = TestimonialBlock.fields.find((f) => f.name === "rating")
expect(ratingField?.fieldType).toBe("number")
const styleField = TestimonialBlock.fields.find((f) => f.name === "style")
expect(styleField?.fieldType).toBe("select")
expect(styleField?.options).toEqual(["card", "minimal", "quote"])
})
it("renders with default props", () => {
const Component = TestimonialBlock.component as React.FC<
typeof TestimonialBlock.defaultProps
>
render(<Component {...TestimonialBlock.defaultProps} />)
expect(screen.getByText(/This product changed everything/)).toBeTruthy()
expect(screen.getByText("Jane Doe")).toBeTruthy()
})
})
Run tests with:
npx vitest run blocks/testimonial.test.tsx
10. Checklist
Before considering a block complete:
- Schema uses
z.object()as the root type - Every field is flat — no nested
z.object()fields, and no wrapper besides.default()/.optional() - Every field has
.describe("Label")for a human-readable label - String fields that need special controls have
.meta({ fieldType: "..." }) -
defaultPropsindefineBlock()matches.default()values in the schema - Component handles optional props gracefully (no crashes on
undefined) - Component defaults in the function signature match
defaultProps - Block is added to
createBlockRegistry()in the registry file - Block appears in the Component Library when testing in editor mode
- PropsPanel shows correct field types and labels
- Unit tests cover metadata, field mapping, and rendering
- After deploying, opened the visual editor once so the CMS snapshot picks up the change
Next Steps
- Integration Guide — full project setup and visual editor integration