@charisol/plexo-sdk
Visual drag-and-drop email & landing page builder SDK with AI integration — built for React.
Features
- 🎨 Visual drag-and-drop builder — rows, columns, and elements with live editing
- 📧 Email & Landing Page modes — export clean, production-ready HTML for both
- 🤖 AI content generation — edit a single element, edit the whole layout, or generate a fresh layout from a prompt, with support for OpenAI, Gemini, and Claude
- 🔒 Server-proxied AI, no exposed keys — your provider API key is never sent to or stored in the browser; every AI request is authenticated with your Plexo API key and resolved against your account's server-side configuration
- 💬 Claude-Code-style AI feedback — a loading bubble followed by a one-line summary of what changed, for both the per-element editor and the layout prompt bar
- 🖼️ Stock image search — Unsplash, Pexels, Pixabay integrations
- 🎨 Design token system — Strata token support for consistent brand theming
- 📦 ESM-only, pure client-side component — nothing to self-host; the SDK talks directly to Plexo's hosted API for AI/publish/compile using your Plexo API key
Installation
npm install @charisol/plexo-sdk
Peer dependencies (install if not already present):
npm install react react-dom
Quick Start
Plain React (Vite / CRA)
import { useRef } from 'react';
import { PlexoBuilder, type PlexoBuilderRef } from '@charisol/plexo-sdk';
// Import the builder's stylesheet
import '@charisol/plexo-sdk/dist/plexo-sdk.css';
export function Editor() {
const builderRef = useRef<PlexoBuilderRef>(null);
const handleSave = async () => {
const result = await builderRef.current?.exportDesign('email');
console.log('HTML:', result?.html);
console.log('JSON:', result?.json);
};
return (
<div style={{ height: '100vh' }}>
<PlexoBuilder
ref={builderRef}
apiKey="your-api-key"
mode="email"
backgroundColor="#ffffff"
themeBgColor="#6d28d9"
themeFgColor="#ffffff"
textColor="#111827"
showSaveButton={false}
/>
<button onClick={handleSave}>Save</button>
</div>
);
}
Next.js (App Router)
Important: The SDK is a purely client-side component that uses drag-and-drop and browser APIs. It must never be rendered on the server.
Step 1 — Create a dynamic wrapper
Because the SDK uses browser-only APIs, wrap it with next/dynamic and disable SSR. Be sure to import the CSS stylesheet:
// components/template-editor.tsx
'use client';
import dynamic from 'next/dynamic';
import type { PlexoBuilderRef } from '@charisol/plexo-sdk';
import { useRef } from 'react';
// Import the builder's stylesheet
import '@charisol/plexo-sdk/dist/plexo-sdk.css';
// Never SSR the builder — it relies on browser APIs (drag-and-drop, ResizeObserver, etc.)
const PlexoBuilder = dynamic(
() => import('@charisol/plexo-sdk').then((m) => ({ default: m.PlexoBuilder })),
{ ssr: false, loading: () => <p>Loading builder…</p> }
);
export function TemplateEditor() {
const builderRef = useRef<PlexoBuilderRef>(null);
const handleSave = async () => {
const result = await builderRef.current?.exportDesign('email');
console.log(result?.html);
};
return (
<div style={{ height: '100vh' }}>
<PlexoBuilder
ref={builderRef}
apiKey="your-api-key"
mode="email"
backgroundColor="#ffffff"
themeBgColor="#6d28d9"
themeFgColor="#ffffff"
textColor="#111827"
showSaveButton={false}
/>
<button onClick={handleSave}>Save</button>
</div>
);
}
Step 2 — Use in a Server Component page
// app/editor/page.tsx (Server Component — no "use client")
import { TemplateEditor } from '@/components/template-editor';
export default function EditorPage() {
return <TemplateEditor />;
}
Props Reference
PlexoBuilder
| Prop | Type | Default | Description |
|---|---|---|---|
mode | 'email' | 'landing_page' | — | Required. Output mode for the editor |
apiKey | string | — | Required. Your Plexo API key |
initialTemplate | TemplateJSON | — | Pre-load a saved template JSON |
initialHtml | string | — | Pre-load raw HTML (landing page mode) |
initialCss | string | — | Pre-load CSS (landing page mode) |
initialJs | string | — | Pre-load JS (landing page mode) |
showSaveButton | boolean | true | Show/hide the built-in save button |
onSave | (data: { json, html }) => void | — | Callback when built-in save button is clicked |
autoSave | boolean | false | Auto-save periodically |
autoSaveDuration | number | 3000 | Auto-save interval in ms |
onUpload | (file: File) => Promise<string> | string | — | Custom image upload handler, returns URL |
backgroundColor | string | '#0e1526' | Canvas background colour |
themeBgColor | string | '#4f46e5' | Primary brand/accent colour |
themeFgColor | string | '#ffffff' | Foreground colour on brand elements |
textColor | string | '#e2e8f0' | Default body text colour |
useAi | boolean | false | Enable AI content generation. Also gates locally — if false, AI UI is hidden/disabled without a round trip |
aiProvider | 'openai' | 'gemini' | 'claude' | string | 'openai' | Display/label only. The provider actually used is resolved server-side from your Plexo account's configuration — this prop does not select or override it |
aiTier | 'AUTO' | 'BASIC' | 'MEDIUM' | 'HIGH' | string | 'AUTO' | Requested model tier, sent with each AI request as an override of your account's default tier. AUTO lets the server pick BASIC/MEDIUM/HIGH per-request based on prompt complexity |
unsplashKey | string | — | Unsplash API key for stock images |
pexelsKey | string | — | Pexels API key for stock images |
pixabayKey | string | — | Pixabay API key for stock images |
readOnly | boolean | false | Forces the canvas into read-only preview (no drag-and-drop, no property panels, no Save button) regardless of the built-in Preview/Edit toggle. For host apps enforcing their own permission model (e.g. a viewer/commenter collaborator role). onRequestComment still fires while readOnly is on. |
commentMode | boolean | false | When on, clicking any row/column/element calls onRequestComment with that node's id/type instead of selecting it for editing — the building block for a Figma-style "click to pin a comment" host feature. |
onRequestComment | (target: CommentTarget) => void | — | Fired on canvas click while commentMode is on. CommentTarget is { nodeId, nodeType: 'row' | 'column' | 'element', clientX, clientY }. |
AI Configuration
AI generation (per-element editing, whole-layout editing, and generating a brand-new layout when the canvas is empty) runs entirely server-side. There is no aiApiKey prop and never will be — a provider API key (OpenAI, Anthropic, or Gemini) is configured once against your Plexo account and stays there, encrypted at rest. The SDK authenticates AI requests with the same apiKey you already pass to PlexoBuilder; the server resolves your account's provider, key, and default tier from that.
<PlexoBuilder
apiKey="your-plexo-api-key"
useAi
aiTier="AUTO" // optional override: 'AUTO' | 'BASIC' | 'MEDIUM' | 'HIGH'
// no aiApiKey, no aiProvider selection — both are account-level, not props
...
/>
Tiers, from cheapest/fastest to most capable: BASIC → MEDIUM → HIGH. AUTO (the default) classifies each prompt's complexity server-side and picks the right tier automatically — a quick "change the button color" won't spend the same budget as "redesign this whole landing page."
Per-template override: TemplateJSON.body.aiConfig can override useAi/tier for a single template:
aiConfig?: {
useAi?: boolean;
tier?: string;
}
Notice there's no apiKey/provider field here either — a template's JSON is loaded into the browser wholesale for editing, so a secret stored inside it could never actually be kept safe. The real key always comes from your account's server-side configuration, never from the template.
Where AI requests actually go: if apiKey is "workspace-internal" (the sentinel used when the SDK is rendered by Plexo's own dashboard), requests stay same-origin. For every real Plexo API key — i.e. any third-party integration — requests are routed automatically to Plexo's hosted production API. There's nothing to configure. If a request ever fails with a 404 here, it means this package version is out of date; update with npm install @charisol/plexo-sdk@latest.
PlexoBuilderRef — Imperative API
Access via ref on the component:
const builderRef = useRef<PlexoBuilderRef>(null);
// Export the current design
const { json, html } = await builderRef.current.exportDesign('email');
// or:
const { json, html } = await builderRef.current.exportDesign('landing_page');
// Get the JSON without compiling HTML
const json = builderRef.current.getJson();
| Method | Signature | Description |
|---|---|---|
exportDesign | (target: 'email' | 'landing_page') => Promise<{ json: TemplateJSON; html: string }> | Compile & export the current design |
getJson | () => TemplateJSON | Get the raw design JSON snapshot |
getNodeRect | (nodeId: string) => DOMRect | null | On-screen bounding rect of a row/column/element by its JSON id, or null if not currently rendered. Re-call on scroll/resize/device-view-change. Useful for positioning host UI (e.g. a comment pin) over a specific canvas node. |
getCanvasContainerRect | () => DOMRect | null | On-screen bounding rect of the canvas viewport itself. |
V2 editor — @charisol/plexo-sdk/v2
A Figma-style visual builder for landing pages and app screens: free, flow or grid layout, per-screen (desktop / tablet / mobile) layouts, and nine ready-made interactive elements (carousel, video, social links, menu, HTML embed, table, countdown timer, form, accordion). A design is plain JSON — an array of pages, each a tree of nodes — and a compiler turns it into HTML or a React component.
import { V2Editor } from '@charisol/plexo-sdk/v2';
import '@charisol/plexo-sdk/dist/plexo-sdk.css';
// Works with no props at all: opens a blank page, fully offline.
<div style={{ height: '100vh' }}>
<V2Editor pages={savedPages} onChange={setPages} onSave={(pages) => api.save(pages)} />
</div>
The editor fills its container (give the parent a height), and it is client-only like V1 — wrap it in next/dynamic with ssr: false in Next.js.
What is a default, what is a prop, what is separate
| Where it lives | Examples | |
|---|---|---|
| SDK default — works with no configuration | inside the editor | the whole editing UI, undo (one step per gesture), snapping, desktop/tablet/mobile frames, keyboard shortcuts (scoped to the editor, never the host page), the colour picker, Fit-to-screen, the nine elements, image upload that embeds small images, a blank starting page |
| Host prop — needs the host's server, accounts or secrets | <V2Editor … /> props | saving (onSave, onPublish, autoSave), files (onUpload, onUploadFont, media, stockImages), AI (onAskSol), brand colours (colors), ready-made sections (sections), link targets (linkTargets), where forms send (formAction, apiKey, templateId), permissions (readOnly, commentMode), header slots, turning panels off (features) |
| Separate entry — no React, runs on a server | @charisol/plexo-sdk/v2/compile | compilePageToHtml, compilePageToReact, parsePages, convertLegacyDesign, frame sizes, computePlacements |
Streaming SOL. onAskSol(question, context, io) may stream: call io.onText(replySoFar) with the whole reply each time more arrives (the panel shows it growing), and watch io.signal — it aborts when the owner presses Stop. Return the full reply at the end. Hosts that ignore io and just return the reply keep working. Add solAutoApply and each change SOL writes (the fenced plexo-commands list) is made on the page the moment it has arrived, so the page changes while SOL is still answering; one Undo takes back the whole answer. Without solAutoApply the owner still reviews and presses Apply. context also carries nodeName and the panel's earlier history, so follow-ups like "make it shorter" make sense. readStreamingSolReply (exported) is the incremental reader, if a host builds its own panel.
Nothing is faked when a prop is missing: with no onAskSol the SOL button is hidden, with no onSave there is no Save button, with no onUpload images are embedded in the design (up to 1.5 MB) with a clear message beyond that; with no onUploadFont the font picker offers Google Fonts only (no "Add a font" button). Uploaded fonts are stored in the design system (designSystem.fonts) and compiled to @font-face rules; if the host stores font files on another origin, pass it to sanitizeV2Html(html, { fontOrigins: [...] }). compilePageToHtml also returns head (font <link> tags) and fontLinks for hosts that place html + css in their own page. No provider key ever lives in the browser — AI and stock-image search go through the host, as in V1.
Theming the editor
The editor's own colours (not your visitors' page) follow three props. Pass any of them; every other shade (panels, borders, hover states, muted text) is mixed from these, so a light background with dark text works as well as a dark one. Nothing passed = Plexo's lime-on-black look.
<V2Editor theme={{ accent: '#96F02E', background: '#000000', text: '#ffffff' }} />
Emails
The same editor designs emails: <V2Editor mode="email" />. In email mode only what every email app can draw is offered: text, buttons, pictures, dividers, spacing and rows of columns (which stack on a phone), in ready-made email sections (header, main message, text, highlights, call to action, picture, footer). There is no free placement, grid, shadow, animation or script, and Preview shows the email as it will arrive. Write the finished email with the compiler (tables with inline styles, plus a plain-text version):
import { compilePageToEmail, emailIssues, starterEmailPages } from '@charisol/plexo-sdk/v2/compile';
const { html, text, warnings } = compilePageToEmail(page, { designSystem, preheader: 'Your account is ready' });
Merge tags such as {{first_name}} pass through untouched for your sender to fill in.
Where a form sends messages
In order: the form's own address (the owner typed one) → the host's formAction(formId) → inside Plexo (apiKey="workspace-internal" plus templateId) Plexo's own form inbox → nothing, which the editor and the compiler both say plainly. A third-party app with a real Plexo key doesn't get Plexo's inbox (it finds the site from the page's own domain), so it passes formAction.
Compiling
import { compilePageToHtml, compilePageToReact, parsePages } from '@charisol/plexo-sdk/v2/compile';
const parsed = parsePages(json); // untrusted JSON in, plain-language error out
if (!parsed.ok) throw new Error(parsed.error);
// Landing pages / templates → HTML
const { html, css, script, document, warnings } = compilePageToHtml(parsed.pages[0], {
apiKey: 'workspace-internal', templateId, // inside Plexo: forms work with no setup
// formAction: (id) => `https://api.acme.test/leads?form=${id}`, // any other host
// runtime: 'none', // host forbids scripts on published pages
// allowEmbedScripts: false, // …and wants HTML embeds script-free too
});
// App projects → a standalone React component (imports only "react")
const { code } = compilePageToReact(parsed.pages[0], { componentName: 'HomePage' });
Everything works as plain HTML where the browser allows it (scroll-snap carousel, native accordion and mobile menu, real table, form and video). The optional runtime adds carousel autoplay and arrows, the live timer, and sending a form without leaving the page; hosts that don't allow scripts leave it out with runtime: 'none'. warnings are plain-language things the owner should fix (a form with nowhere to send messages, a screen missing elements the desktop has).
The visible canvas
Each screen (desktop 1200, tablet 768, mobile 375) is a frame. What is inside a frame is the page on that screen; what is outside is working area that is shown, dimmed, only while editing:
- an element entirely outside a screen's frame is not published on that screen (and is left out of the page altogether if no screen shows it);
- an element partly outside is clipped at its section's edge;
- the editor counts what's outside, and offers Fit to this screen (mobile: one stacked column; tablet: the layout scaled to width) and Bring into view. The compiler warns when a screen is missing elements the desktop has.
Since a section clips at its edge, leave room under things that grow (an accordion, a form with an error message).
Untrusted input
A design can come from a database, an AI or a third-party app, so the compilers treat it as untrusted: text and attributes are escaped, link and image schemes are allow-listed, CSS values that could add declarations or fetch URLs are dropped, class names are restricted, embeds run in a sandbox, and parsePages rejects unknown element types, duplicate ids and oversized trees. Don't pass V2 HTML through V1's sanitizeCompiledHtml. It keeps V2's stylesheet and layout, but removes <details>, <iframe>, <figure> and <script> — which silently breaks the accordion, the phone menu, video, HTML embeds and the carousel. V2's output is built from an allow-list of tags and attributes, so it doesn't need that step; a host that must sanitize should use a sanitizer that allows those tags.
Upgrading from the previous V2
The previous V2 (V2Canvas, useCanvasStore, compilePageToReact(nodes, pageOrder, …)) is replaced; this is a breaking change to @charisol/plexo-sdk/v2 and /v2/compile. Designs saved by the old version still open: isLegacyDesign(doc) recognises them and convertLegacyDesign(doc, { name, slug }) upgrades one to a page (the old pages scaled to fit any screen, so tablet and mobile get matching scaled layouts). Not carried over: brand text-style links and pinned sections.
Framework Notes
Vite
Works out of the box — no extra config needed.
Next.js (Pages Router)
Same dynamic import pattern applies:
import dynamic from 'next/dynamic';
const PlexoBuilder = dynamic(
() => import('@charisol/plexo-sdk').then((m) => ({ default: m.PlexoBuilder })),
{ ssr: false }
);
Remix / React Router v7
Use the lazy approach or a client-only guard:
import { lazy, Suspense } from 'react';
const PlexoBuilder = lazy(() =>
import('@charisol/plexo-sdk').then((m) => ({ default: m.PlexoBuilder }))
);
// In your component:
<Suspense fallback={<p>Loading…</p>}>
<PlexoBuilder ... />
</Suspense>
Note: In Remix, place this inside a
clientLoaderor use<ClientOnly>wrapper to ensure it's never executed on the server.
Changelog
1.1.0
- New
readOnly,commentMode, andonRequestCommentprops, plusgetNodeRect/getCanvasContainerRecton thePlexoBuilderRef— lets a host build Figma-style anchored comments on top of the canvas (click a node whilecommentModeis on to get its id/type/coordinates, position an overlay viagetNodeRect, enforce a read-only collaborator role viareadOnly) without needing to know the canvas's internal DOM structure. Fully backwards compatible — no existing prop or ref method changed. - Every row/column/element wrapper now carries a
data-plexo-node-type="row"|"column"|"element"attribute alongside its existingid(already the node's stable JSON id).
Unreleased
- Breaking: Removed the
aiApiKeyprop. AI generation now runs server-side — your provider key is configured once against your Plexo account (encrypted at rest) instead of being passed to the browser on every render. - Breaking:
TemplateJSON.body.aiConfigno longer acceptsapiKey/provider— onlyuseAi/tierremain, for the same reason (a template's JSON is loaded client-side, so it can't hold a secret safely). aiProvideris now display/label-only; the effective provider comes from your account configuration, not this prop.- New Claude-Code-style AI feedback: a loading bubble followed by a one-line summary of what changed, for both the per-element "Edit with AI" popup and the layout AI prompt bar.
- The layout AI prompt bar now generates a brand-new layout automatically when the canvas is empty, instead of only editing existing content.
- AI requests (and publish/validate-key requests, when using a real Plexo API key rather than the dashboard's internal session) now route automatically to Plexo's hosted production API — nothing to configure. A 404 here means the installed package version is stale; update to the latest.
1.0.1
- Fixed: Removed Rolldown CJS interop shim (
require()) from ESM output — resolves compatibility issues with Turbopack (Next.js 15+/16+) and strict ESM environments - All React sub-paths (
react/jsx-runtime, etc.) are now properly externalized
1.0.0
- Initial release
License
MIT © Plexo HQ