dragble-ai — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited dragble-ai (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
The Dragble editor provides two independently configured subsystems:
AI Features — Generate headings, body text, button labels, images, and alt text directly inside the editor. Three modes:
| Mode | Description | Requires |
|---|---|---|
dragble | Built-in AI powered by Dragble servers (paid plans) | Active subscription with AI credits |
external | Bring Your Own Key / backend — works on any plan | You provide callback functions that call your own API |
disabled | All AI features hidden from the editor UI | Nothing |
Asset Storage — Upload, browse, and manage images used in designs. Two modes:
| Mode | Description | Requires |
|---|---|---|
dragble | Managed cloud storage (Cloudflare R2) — zero config | Active subscription (default) |
external | Your own S3-compatible bucket or custom backend | You provide presign/upload/list callbacks |
Both subsystems are configured through dragble.init() and are completely independent of each other. You can use Dragble AI with external storage, or external AI with Dragble storage, or any combination.
Configure AI mode in dragble.init():
dragble.init({
containerId: 'editor-container',
projectId: 'proj_xxx',
ai: {
mode: 'dragble' // 'dragble' | 'external' | 'disabled'
}
});| Mode | UI Visible | Credits Used | Callbacks Required |
|---|---|---|---|
dragble | Yes | Yes (Dragble credits) | No |
external | Yes | No | Yes — you provide one or more feature callbacks |
disabled | No — all AI buttons/menus hidden | No | No |
dragble.init({
containerId: 'editor-container',
projectId: 'proj_xxx',
ai: {
mode: 'dragble'
}
});No callbacks needed. The editor calls Dragble servers directly. AI credits are deducted per request based on token count.
dragble.init({
containerId: 'editor-container',
projectId: 'proj_xxx',
ai: {
mode: 'external',
features: {
smartHeading: {
enabled: true,
generate: async (params) => {
const res = await fetch('https://your-api.com/ai/headings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return res.json();
}
}
// ... other features
}
}
});You can set the global mode to dragble but override specific features with external callbacks. The editor resolves the mode per-feature:
dragble.init({
containerId: 'editor-container',
projectId: 'proj_xxx',
ai: {
mode: 'dragble', // global default
features: {
// This feature uses YOUR backend instead of Dragble AI
imageGeneration: {
enabled: true,
generate: async (params) => {
const res = await fetch('https://your-api.com/ai/images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return res.json();
}
},
// smartHeading, smartText, etc. still use Dragble AI
}
}
});The editor resolves which AI provider to use per-feature in this order:
ai.mode is 'disabled', all AI features are hidden. Stop.enabled: false, that feature is hidden. Skip it.generate callback, use external mode for that feature regardless of global mode.ai.mode setting ('dragble' or 'external').If global mode is 'external' but a feature has no generate callback, that feature is silently disabled.
#### 1. Smart Heading
Generate contextual headings for content sections.
Callback Signature:
generate: (params: {
context: string; // surrounding content / section context
tone?: string; // 'professional' | 'casual' | 'formal' | 'friendly' | 'urgent'
headingType?: string; // 'h1' | 'h2' | 'h3' | 'subject' | 'preheader'
industry?: string; // e.g. 'ecommerce', 'saas', 'healthcare'
count?: number; // number of suggestions (default: 5)
}) => Promise<{
headings: string[]; // array of heading suggestions
}>External Example:
smartHeading: {
enabled: true,
generate: async ({ context, tone, headingType, industry, count }) => {
const res = await fetch('https://your-api.com/ai/headings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ context, tone, headingType, industry, count })
});
const data = await res.json();
return { headings: data.headings };
}
}#### 2. Smart Text
Generate body copy, paragraphs, or text blocks.
Callback Signature:
generate: (params: {
topic: string; // main topic or brief
purpose?: string; // 'marketing' | 'informational' | 'transactional' | 'welcome'
tone?: string; // 'professional' | 'casual' | 'formal' | 'friendly' | 'urgent'
length?: string; // 'short' | 'medium' | 'long'
industry?: string; // e.g. 'ecommerce', 'saas', 'healthcare'
keywords?: string[]; // SEO or brand keywords to include
}) => Promise<{
text: string; // primary generated text
alternatives?: string[]; // optional alternative versions
}>External Example:
smartText: {
enabled: true,
generate: async ({ topic, purpose, tone, length, industry, keywords }) => {
const res = await fetch('https://your-api.com/ai/text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ topic, purpose, tone, length, industry, keywords })
});
const data = await res.json();
return { text: data.text, alternatives: data.alternatives };
}
}#### 3. Smart Button
Generate call-to-action button labels.
Callback Signature:
generate: (params: {
action: string; // what the button does (e.g. 'sign up', 'buy now')
context?: string; // surrounding content for relevance
urgency?: string; // 'low' | 'medium' | 'high'
style?: string; // 'formal' | 'casual' | 'playful' | 'direct'
count?: number; // number of suggestions (default: 5)
}) => Promise<{
buttons: string[]; // array of button label suggestions
}>External Example:
smartButton: {
enabled: true,
generate: async ({ action, context, urgency, style, count }) => {
const res = await fetch('https://your-api.com/ai/buttons', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ action, context, urgency, style, count })
});
const data = await res.json();
return { buttons: data.buttons };
}
}#### 4. Image Generation
Generate images from text prompts.
Callback Signature:
generate: (params: {
prompt: string; // text description of desired image
negativePrompt?: string; // what to avoid in the image
style?: string; // 'photorealistic' | 'illustration' | 'flat' | '3d' | 'watercolor'
aspectRatio?: string; // '1:1' | '16:9' | '4:3' | '9:16'
count?: number; // number of images to generate (default: 1)
}) => Promise<{
images: Array<{
url: string; // publicly accessible image URL
}>;
}>External Example:
imageGeneration: {
enabled: true,
generate: async ({ prompt, negativePrompt, style, aspectRatio, count }) => {
const res = await fetch('https://your-api.com/ai/images/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ prompt, negativePrompt, style, aspectRatio, count })
});
const data = await res.json();
return { images: data.images.map(img => ({ url: img.url })) };
}
}#### 5. Image Search
Search stock photo libraries or your own image database.
Callback Signature:
generate: (params: {
query: string; // search query
orientation?: string; // 'landscape' | 'portrait' | 'square'
color?: string; // dominant color filter
perPage?: number; // results per page (default: 20)
page?: number; // pagination page number (default: 1)
}) => Promise<{
images: Array<{
id: string; // unique image identifier
source: string; // source name (e.g. 'unsplash', 'your-cdn')
url: string; // full-size image URL
thumbUrl: string; // thumbnail URL for preview grid
width: number; // image width in pixels
height: number; // image height in pixels
alt: string; // alt text / description
}>;
total: number; // total results available for pagination
}>External Example:
imageSearch: {
enabled: true,
generate: async ({ query, orientation, color, perPage, page }) => {
const res = await fetch(`https://your-api.com/ai/images/search?q=${encodeURIComponent(query)}&orientation=${orientation || ''}&color=${color || ''}&per_page=${perPage || 20}&page=${page || 1}`, {
headers: { 'Authorization': 'Bearer YOUR_TOKEN' }
});
const data = await res.json();
return {
images: data.results.map(img => ({
id: img.id,
source: 'your-cdn',
url: img.url,
thumbUrl: img.thumb,
width: img.width,
height: img.height,
alt: img.description || ''
})),
total: data.total
};
}
}#### 6. Alt Text
Generate accessible alt text for images.
Callback Signature:
generate: (params: {
imageUrl: string; // URL of the image to describe
context?: string; // surrounding content for relevance
maxLength?: number; // max characters for alt text (default: 125)
}) => Promise<{
altText: string; // concise alt text for the image
description?: string; // optional longer description
}>External Example:
altText: {
enabled: true,
generate: async ({ imageUrl, context, maxLength }) => {
const res = await fetch('https://your-api.com/ai/alt-text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ imageUrl, context, maxLength })
});
const data = await res.json();
return { altText: data.altText, description: data.description };
}
}Full init example with ALL AI features wired to an external backend:
dragble.init({
containerId: 'editor-container',
projectId: 'proj_xxx',
ai: {
mode: 'external',
features: {
smartHeading: {
enabled: true,
generate: async ({ context, tone, headingType, industry, count }) => {
const res = await fetch('https://your-api.com/ai/headings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ context, tone, headingType, industry, count })
});
return res.json(); // { headings: string[] }
}
},
smartText: {
enabled: true,
generate: async ({ topic, purpose, tone, length, industry, keywords }) => {
const res = await fetch('https://your-api.com/ai/text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ topic, purpose, tone, length, industry, keywords })
});
return res.json(); // { text: string, alternatives?: string[] }
}
},
smartButton: {
enabled: true,
generate: async ({ action, context, urgency, style, count }) => {
const res = await fetch('https://your-api.com/ai/buttons', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ action, context, urgency, style, count })
});
return res.json(); // { buttons: string[] }
}
},
imageGeneration: {
enabled: true,
generate: async ({ prompt, negativePrompt, style, aspectRatio, count }) => {
const res = await fetch('https://your-api.com/ai/images/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ prompt, negativePrompt, style, aspectRatio, count })
});
return res.json(); // { images: [{ url: string }] }
}
},
imageSearch: {
enabled: true,
generate: async ({ query, orientation, color, perPage, page }) => {
const res = await fetch(`https://your-api.com/ai/images/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ query, orientation, color, perPage, page })
});
return res.json(); // { images: [{ id, source, url, thumbUrl, width, height, alt }], total }
}
},
altText: {
enabled: true,
generate: async ({ imageUrl, context, maxLength }) => {
const res = await fetch('https://your-api.com/ai/alt-text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ imageUrl, context, maxLength })
});
return res.json(); // { altText: string, description?: string }
}
}
}
}
});After initialization, you can query AI state:
// Check if AI is completely disabled
const disabled = dragble.isAIDisabled();
// Returns: true if ai.mode === 'disabled'
// Get the current AI mode
const mode = dragble.getAIMode();
// Returns: 'dragble' | 'external' | 'disabled'Configure storage mode in dragble.init():
dragble.init({
containerId: 'editor-container',
projectId: 'proj_xxx',
assetStorage: {
mode: 'dragble' // 'dragble' | 'external'
}
});| Mode | Upload Target | Asset Management | Config Required |
|---|---|---|---|
dragble | Dragble managed cloud (Cloudflare R2) | Built-in asset manager | None (default) |
external | Your S3-compatible bucket or custom backend | You provide callbacks | Yes — at minimum getPresignUrl and onUpload |
No configuration needed. Images are uploaded to Dragble's managed cloud and served via CDN:
dragble.init({
containerId: 'editor-container',
projectId: 'proj_xxx'
// assetStorage defaults to { mode: 'dragble' }
});For external storage, you must provide callback functions that handle presigned URL generation, upload confirmation, and optionally asset listing and folder management.
Callbacks:
| Callback | Required | Purpose |
|---|---|---|
getPresignUrl | Yes | Generate a presigned upload URL for the file |
onUpload | Yes | Confirm upload completion, return the final public URL |
onLoadAssets | Optional | List/paginate assets in the asset manager |
onDeleteAsset | Optional | Delete an asset from your storage |
onLoadFolders | Optional | List folders in the asset manager |
onCreateFolder | Optional | Create a new folder |
onDeleteFolder | Optional | Delete a folder |
onMoveAsset | Optional | Move an asset between folders |
Callback Signatures:
getPresignUrl: (params: {
fileName: string; // original file name
fileType: string; // MIME type (e.g. 'image/png')
fileSize: number; // file size in bytes
folder?: string; // target folder path
}) => Promise<{
uploadUrl: string; // presigned PUT URL
fileUrl: string; // final public URL after upload
fields?: Record<string, string>; // optional form fields for POST uploads
}>
onUpload: (params: {
fileUrl: string; // the public URL returned by getPresignUrl
fileName: string; // original file name
fileType: string; // MIME type
fileSize: number; // file size in bytes
folder?: string; // folder path
}) => Promise<{
url: string; // confirmed public URL for the uploaded asset
id?: string; // optional asset ID for management
}>
onLoadAssets: (params: {
folder?: string; // folder path to list (root if empty)
page?: number; // pagination page
perPage?: number; // items per page
search?: string; // search query
}) => Promise<{
assets: Array<{
id: string;
url: string;
thumbUrl?: string;
name: string;
fileType: string;
fileSize: number;
folder?: string;
createdAt?: string;
}>;
total: number;
}>
onDeleteAsset: (params: {
id: string; // asset ID
url: string; // asset URL
}) => Promise<{ success: boolean }>
onLoadFolders: (params: {
parentFolder?: string; // parent folder path
}) => Promise<{
folders: Array<{
id: string;
name: string;
path: string;
assetCount?: number;
}>;
}>
onCreateFolder: (params: {
name: string; // folder name
parentFolder?: string; // parent folder path
}) => Promise<{
id: string;
name: string;
path: string;
}>
onDeleteFolder: (params: {
id: string; // folder ID
path: string; // folder path
}) => Promise<{ success: boolean }>
onMoveAsset: (params: {
assetId: string; // asset ID to move
fromFolder: string; // source folder path
toFolder: string; // destination folder path
}) => Promise<{ success: boolean }>When a user uploads an image in the editor with external storage configured:
fileName, fileType, fileSize, and optional folder. Your backend generates a presigned S3 PUT URL (or equivalent) and returns uploadUrl + fileUrl.uploadUrl returned in step 2. The upload goes browser-to-storage with no Dragble server in the middle.onUpload so your backend can record the asset in its database and confirm the final URL.url from onUpload is inserted into the design.onLoadAssets and onLoadFolders are called to populate the UI.dragble.init({
containerId: 'editor-container',
projectId: 'proj_xxx',
assetStorage: {
mode: 'external',
getPresignUrl: async ({ fileName, fileType, fileSize, folder }) => {
const res = await fetch('https://your-api.com/storage/presign', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ fileName, fileType, fileSize, folder })
});
return res.json(); // { uploadUrl, fileUrl }
},
onUpload: async ({ fileUrl, fileName, fileType, fileSize, folder }) => {
const res = await fetch('https://your-api.com/storage/confirm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ fileUrl, fileName, fileType, fileSize, folder })
});
return res.json(); // { url, id }
},
onLoadAssets: async ({ folder, page, perPage, search }) => {
const params = new URLSearchParams({
...(folder && { folder }),
...(page && { page: String(page) }),
...(perPage && { perPage: String(perPage) }),
...(search && { search })
});
const res = await fetch(`https://your-api.com/storage/assets?${params}`, {
headers: { 'Authorization': 'Bearer YOUR_TOKEN' }
});
return res.json(); // { assets: [...], total }
},
onDeleteAsset: async ({ id, url }) => {
const res = await fetch(`https://your-api.com/storage/assets/${id}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer YOUR_TOKEN' }
});
return res.json(); // { success: true }
},
onLoadFolders: async ({ parentFolder }) => {
const params = parentFolder ? `?parent=${encodeURIComponent(parentFolder)}` : '';
const res = await fetch(`https://your-api.com/storage/folders${params}`, {
headers: { 'Authorization': 'Bearer YOUR_TOKEN' }
});
return res.json(); // { folders: [...] }
},
onCreateFolder: async ({ name, parentFolder }) => {
const res = await fetch('https://your-api.com/storage/folders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ name, parentFolder })
});
return res.json(); // { id, name, path }
},
onDeleteFolder: async ({ id, path }) => {
const res = await fetch(`https://your-api.com/storage/folders/${id}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer YOUR_TOKEN' }
});
return res.json(); // { success: true }
},
onMoveAsset: async ({ assetId, fromFolder, toFolder }) => {
const res = await fetch(`https://your-api.com/storage/assets/${assetId}/move`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ fromFolder, toFolder })
});
return res.json(); // { success: true }
}
}
});After initialization, you can query and interact with storage:
// Check if external storage is configured
const isExternal = dragble.isExternalStorage();
// Returns: true if assetStorage.mode === 'external'
// Get the current storage mode
const mode = dragble.getAssetStorageMode();
// Returns: 'dragble' | 'external'
// Programmatically upload an image (works with both modes)
const result = await dragble.uploadImage(file);
// file: File object
// Returns: { url: string } — the public URL of the uploaded image@dragble/editor-sdkThe SDK is loaded via CDN script tag. There is no npm package to import.
// WRONG - will not work
import { DragbleSDK } from '@dragble/editor-sdk';
// CORRECT - use the global
dragble.init({ ... });If your callback returns a different structure, the editor will silently fail or show empty results. Match the return types exactly as documented above.
// WRONG - missing wrapper object
generate: async (params) => {
const headings = await fetchHeadings(params);
return headings; // string[] directly
}
// CORRECT - wrapped in expected shape
generate: async (params) => {
const headings = await fetchHeadings(params);
return { headings }; // { headings: string[] }
}If ai.mode is 'external' but you don't provide any feature callbacks, all AI features are silently disabled. The editor does not fall back to Dragble AI in external mode.
Your S3 presigned URL must have proper CORS headers allowing PUT from the editor's origin. Common issues:
Access-Control-Allow-Origin header on the S3 bucket CORS configContent-Type mismatch between the presigned URL and the actual uploadonUpload must return the final public URLThe URL returned by onUpload is what gets embedded in the design HTML. If it returns a temporary or internal URL, images will break when the email is sent.
Setting ai.mode: 'disabled' does not affect asset storage. Setting assetStorage.mode: 'external' does not affect AI. Configure each separately.
If you set ai.mode: 'dragble' but the project's subscription has no AI credits remaining, AI requests will fail with a credits-exhausted error. The editor will show an appropriate message to the user.
URLs returned by imageGeneration.generate must be publicly accessible (no auth required). The editor inserts these URLs directly into the design. If the URLs require authentication or expire quickly, images will break in the final email.
getPresignUrl fields with PUT uploadsIf your presigned URL is for a PUT upload, do not return fields in the getPresignUrl response. The fields property is only for POST-based multipart form uploads (e.g., S3 POST policy). Mixing them will cause upload failures.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.