@krios/custom-element-sdk
Build a custom field type by hosting a small web app and registering its URL with Krios. The CMS embeds it in an iframe inside the entry editor; the SDK handles the postMessage protocol both sides need.
Install
In your widget project:
pnpm add @krios/custom-element-sdk
The package is zero-runtime-deps, ESM + CJS + IIFE, works in any web stack.
Minimal widget
<!doctype html>
<html>
<body>
<input id="value" />
<script type="module">
import { createKriosCustomElement } from "@krios/custom-element-sdk";
const krios = createKriosCustomElement();
const input = document.getElementById("value");
// Initial state from the CMS.
krios.init((ctx) => {
input.value = ctx.value ?? "";
input.disabled = ctx.disabled;
});
// Push edits back to the CMS.
input.addEventListener("input", () => {
krios.setValue(input.value);
});
// Auto-size the iframe.
const ro = new ResizeObserver(() => {
krios.setHeight(document.body.scrollHeight);
});
ro.observe(document.body);
</script>
</body>
</html>
API
interface KriosCustomElement {
init(callback: (context: CustomElementContext) => void): void;
getValue(): unknown;
setValue(value: unknown): void;
setHeight(height: number): void;
onValueChanged(callback: (value: unknown) => void): void;
onLocaleChanged(callback: (locale: string) => void): void;
onDisabledChanged(callback: (disabled: boolean) => void): void;
}
interface CustomElementContext {
value: unknown;
config: unknown; // merged from registration's defaultConfig
locale: string;
entryId: string;
contentType: string;
fieldApiName: string;
disabled: boolean;
projectSlug: string;
endpoint: string;
}
The singleton import works for callers that don't want to thread the bridge:
import { krios } from "@krios/custom-element-sdk";
krios.init((ctx) => { /* ... */ });
krios.setValue(42);
PostMessage protocol
You don't need to implement this directly — the SDK handles it. For reference:
parent → iframe:
{ type: "krios:init", context: CustomElementContext }
{ type: "krios:valueChanged", value }
{ type: "krios:disabledChanged", disabled }
{ type: "krios:localeChanged", locale }
iframe → parent:
{ type: "krios:setValue", value }
{ type: "krios:setHeight", height }
{ type: "krios:ready" }
The SDK auto-emits krios:ready on load so the parent knows when to send krios:init. Origin checks happen on both sides. The parent (Krios admin) is the security boundary: it only accepts messages whose event.origin matches the origin of the registered hostedUrl — an allow-list derived from the registration — and fails closed (drops all messages) if that URL can't be parsed. On the widget side, the SDK validates the parent origin against expectedParentOrigin; pass it explicitly in production. It defaults to the document.referrer origin and falls back to "*" only when no stable parent origin is available, so don't rely on the widget-side check as your trust boundary.
Register the field type in Krios
In the admin UI: Settings → Custom fields → Register custom field.
Or via REST:
POST /api/v1/projects/{slug}/custom-field-types
{
"name": "Color picker",
"apiName": "colorPicker",
"hostedUrl": "https://my-widget.example.com", # HTTPS only
"validationSchema": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$"
},
"defaultConfig": { "showAlpha": false },
"graphqlTypeName": "BrandColor" # optional — named GraphQL output type (V3.9)
}
Once registered, colorPicker appears in the field-type dropdown when editing a content type. Storage uses the custom:colorPicker prefix.
Validation
Krios runs your validationSchema (a JSON Schema) through ajv on every entry save. Empty schema = accept anything the iframe sends. Invalid values are rejected with 422 custom_field_invalid and the ajv error path/keyword in details.
Testing the iframe
Settings → Custom fields → Test opens a dialog that loads the iframe with synthetic test data. Useful for verifying the postMessage round-trip before exposing the field to editors.