Plugin Development — Advanced
The introductory Plugin Development article covers the sandbox model, hook API, and a hello-world example. This article goes deeper: the sandbox lifecycle pool that keeps cold-start latency under 20ms, the AST scanner that blocks dangerous code patterns before a plugin ever runs, the CLI scaffolding tool, and advanced patterns for plugins that need to call external APIs or return modified post content.
Sandbox lifecycle pool (PL2.2)
Creating a new isolated-vm Isolate on every hook invocation costs roughly 80-150ms of cold-start time. For a plugin responding to afterPostPublish, that overhead is invisible to the user. But for hooks that block the publish flow (beforePostPublish), that latency is paid synchronously. The sandbox pool solves this by pre-warming a configurable number of isolates per installed plugin and returning them from a pool rather than creating on demand.
The pool maintains a fixed-size LIFO stack of Isolate handles per plugin ID. On hook invocation: if the pool has an available isolate, it is checked out and returned after execution. If the pool is empty (concurrent calls exhausted it), a new isolate is created on the spot (cold path). After execution, the isolate is reset and returned to the pool — the plugin's module-level state is cleared but the code itself does not need to be recompiled. This keeps warm-path latency under 20ms for most plugins.
| Path | Latency | When it occurs |
|---|---|---|
| Warm (pool hit) | < 20ms | Isolate available in pool — covers most normal-traffic cases |
| Cold (pool miss) | 80–150ms | Pool exhausted by concurrent calls, or first invocation after deploy |
| Timeout | 5000ms | Plugin exceeded CPU time limit — isolate is terminated |
// The pool API is internal — plugin developers don't interact with it directly.
// This shows how the hook runner uses it:
const isolate = await pool.checkout(pluginId);
try {
const result = await isolate.run(hookName, payload, { timeout: 5000 });
return result;
} finally {
pool.checkin(pluginId, isolate); // returns isolate to pool after reset
}AST security scan (PL2.3)
Before any plugin code runs for the first time after install or update, VeloCMS runs a static AST analysis pass over the plugin's compiled JavaScript. The scan uses Acorn to parse the code and walks the AST looking for patterns that indicate sandbox escape attempts or capability violations. A plugin that fails the scan is rejected at install time — it never runs.
The scanner checks for: calls to eval() or new Function(), access to global.process, global.__dirname, or global.__filename, use of require() or import() with any argument (dynamic imports), access to buffer or Buffer constructor, network calls that do not go through velocms.network.fetch, and filesystem API references (fs, path). Obfuscated code that matches known sandbox-escape patterns is also flagged.
| Pattern | Why it's blocked | Error code |
|---|---|---|
| eval() / new Function() | Dynamic code execution can escape sandbox | SEC-001 |
| process.env access | Exposes host environment variables | SEC-002 |
| require() / import() | Node module access bypasses capability manifest | SEC-003 |
| fs.* / path.* | Filesystem access is not available in the sandbox | SEC-004 |
| Buffer() constructor | Buffer can be used to read arbitrary memory in some V8 versions | SEC-005 |
| XMLHttpRequest / fetch (global) | Network must go through velocms.network.fetch only | SEC-006 |
create-velocms-plugin CLI
The fastest way to start a new plugin is the scaffolding CLI. It generates a project directory with all required files: manifest.json with your metadata, index.ts with a typed hook handler skeleton, index.test.ts with a mockVelocms test, tsconfig.json configured for the SDK's target environment, and a package.json with the build and test scripts.
npx create-velocms-plugin@latest
# The CLI prompts for:
# Plugin ID (e.g., my-org.seo-optimizer)
# Display name (e.g., SEO Optimizer)
# Description (1 sentence)
# Author name + email
# Hooks to handle (checkbox list)
# Capabilities needed (checkbox list)
# Pricing model (free / paid)
# Creates:
# my-org.seo-optimizer/
# manifest.json
# src/
# index.ts
# index.test.ts
# tsconfig.json
# package.json
# README.md{
"name": "@my-org/seo-optimizer",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"test": "vitest run",
"dev": "vitest watch"
},
"devDependencies": {
"@velocms/plugin-sdk": "^1.0.0",
"typescript": "^5.0.0",
"vitest": "^2.0.0"
}
}Pattern: calling external APIs
Plugins that need to call external APIs must declare the network capability in their manifest and list the allowed URL prefixes in the allowedUrls array. Any URL not in the allowlist causes a CapabilityError at runtime. The velocms.network.fetch function has the same signature as the standard Fetch API, but requests that do not match an allowed prefix are blocked before leaving the sandbox.
// manifest.json excerpt:
// "capabilities": ["content:read", "network"],
// "networkAllowlist": ["https://api.openai.com/"]
import type { VelocmsPluginAPI, Post } from "@velocms/plugin-sdk";
export async function beforePostPublish(
payload: { post: Post },
velocms: VelocmsPluginAPI
): Promise<Partial<{ post: Post }>> {
const res = await velocms.network.fetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${await velocms.settings.get("my-plugin.api-key")}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: `Suggest SEO improvements for: ${payload.post.title}` }],
}),
}
);
const data = await res.json();
const suggestion = data.choices?.[0]?.message?.content ?? "";
// Attach SEO suggestion as a custom field and return modified post
return {
post: {
...payload.post,
seo_notes: suggestion.slice(0, 500),
},
};
}Pattern: storing persistent state
Plugin isolates are ephemeral — module-level variables reset between invocations. For state that must survive across hook calls (counters, caches, rate-limit windows), use velocms.settings.set() and velocms.settings.get() with plugin-scoped keys. The settings API persists data in the tenant's site_settings record in PocketBase. Values must be JSON-serializable strings.
export async function afterPostPublish(
payload: { post: { id: string } },
velocms: VelocmsPluginAPI
): Promise<void> {
// Read current count (null if first publish)
const raw = await velocms.settings.get("my-plugin.publish-count");
const count = raw ? parseInt(raw, 10) : 0;
// Increment and save
await velocms.settings.set("my-plugin.publish-count", String(count + 1));
if ((count + 1) % 10 === 0) {
velocms.logger.info("Milestone reached", { publishCount: count + 1 });
}
}Pattern: modifying post content
Only beforePostPublish and beforePostCreate hooks can return a modified post payload. The return value is a Partial of the post object — only the fields you include are merged into the original. You do not need to return the full post, just the fields you changed. Returning void (or undefined) from these hooks tells VeloCMS to use the original payload unchanged.
import type { VelocmsPluginAPI, Post } from "@velocms/plugin-sdk";
export async function beforePostPublish(
payload: { post: Post },
velocms: VelocmsPluginAPI
): Promise<Partial<{ post: Post }> | void> {
const { post } = payload;
// Only add reading time if not already set
if (post.reading_time_minutes) return; // void = use original
const wordCount = post.content
? post.content.replace(/<[^>]*>/g, "").split(/s+/).length
: 0;
const readingTime = Math.max(1, Math.round(wordCount / 200));
// Return only the fields that changed
return {
post: { reading_time_minutes: readingTime },
};
}Debugging plugins locally
The isolated-vm sandbox suppresses console.log output to prevent information leakage. To debug during development, use velocms.logger.info() — in test mode this writes to stdout. In the real sandbox, logger output goes to the plugin execution log which you can view in Admin → Settings → Plugins → Execution Log for each installed plugin.
# In your plugin project directory:
# Run tests with watch mode
npm run dev
# Build TypeScript to dist/index.js
npm run build
# Test with the real SDK mock
npm test
# Before submitting, verify the AST scan would pass:
# (the SDK ships a local scan utility)
npx velocms-plugin-scan ./dist/index.jsMarketplace submission
Submissions go through a two-stage review: automated AST scan + capability manifest consistency check (does the code actually use what it declared, and nothing more), followed by human review for UX quality, description accuracy, and pricing honesty. Plan for 3-7 business days on first submission. Updates to existing published plugins are reviewed faster (usually 1-2 business days) if the capability manifest does not expand.
- Build your plugin to dist/index.js via npm run build
- Create a ZIP containing: manifest.json, dist/index.js, and optionally README.md and screenshots/
- Go to velocms.org/developers → Submit a Plugin
- Upload the ZIP and fill in the marketplace listing form
- Automated scan runs immediately — you'll see results within 60 seconds
- If scan passes, the plugin enters the human review queue
- You receive an email when approved, rejected, or when a reviewer leaves comments
Reference
| File / Resource | Purpose |
|---|---|
| src/lib/plugins/builtin/word-counter.ts | Full working built-in plugin — 80 lines showing the complete pattern |
| src/lib/plugins/sandbox/pool.ts | Isolate lifecycle pool implementation (PL2.2) |
| src/lib/plugins/review-pipeline.ts | AST scan + capability consistency check (PL2.3) |
| @velocms/plugin-sdk | npm package: VelocmsPluginAPI types, createMockVelocms |
| plugin-development | Introductory plugin article — sandbox model, hello world |