When Devani embeds an external service on a page (forms, interactive images, and future modules), the integration always has the same five parts. The Sendl forms integration is the canonical reference — read its files side by side with this page.
The five parts
- A settings key —
<service>_api_key, seeded inJsonStore::defaultSettings()(so it back-fills existing installs), overridable viaDEVANI_<SERVICE>_API_KEYenv for managed hosts, listed in the write-only secret-keys set and the MCPallowedIntegrationSettings. - An admin-only proxy — e.g.
devani/sendl-forms.php. Session-authenticated, reads the key server-side, calls the service's list API, returns{configured, items[], error}. The browser never sees the key. Degrade gracefully: no key →configured:false; upstream failure →errorstring, and the editor falls back to manual entry. - An editor block — registered in the block picker (
page-editor.js) with aspecialid. Configured: fetch the proxy, list items to pick. Not configured: show the onboarding card (what the service is + a link to/devani/?settings#<service>_api_key+ the service's dashboard). Always: a manual URL/slug fallback that works with zero setup. The configured flag comes from adata-<service>-configuredattribute PHP puts on the editor panel. - An MCP tool pair —
get_<service>_config(status + guidance + listable items) andinsert_<service>_<thing>(validates the target URL against a strict pattern, snapshots, appends/prepends the block). Add the insert tool towriteTools. - Sanitizer-safe markup — identical between the editor block and the MCP tool:
<div class="devani-module devani-module-sendl" data-devani-module="sendl" data-sendl-form="https://dash.sendl.io/f/contact"> <iframe class="devani-sendl-form" src="..." loading="lazy" style="width:100%;border:0;min-height:900px;"></iframe> </div>data-devani-moduleis on the sanitizer's allowlist; service-specific data uses its own namespace; classes avoid the strippeddevani-inline*/devani-ed*prefixes.loading="lazy"keeps third-party iframes from costing LCP.
Checklist for a new module
- Settings key seeded + env override + both allowlists
- Proxy endpoint, session-gated, key never exposed
- Editor block with onboarding card + manual fallback
- MCP config + insert tools, insert in
writeTools - Markup mirrored editor ↔ MCP, lazy-loaded, sanitizer-tested (add assertions to
tests/sanitizer-test.php) - Verify the target embeds cleanly in an iframe (no
X-Frame-Options/frame-ancestorsblock upstream)