Plugin Development — Advanced

14 min readUpdated 04 Oct 2026

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, 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.

PathLatencyWhen it occurs
Warm (pool hit)< 20msIsolate available in pool — covers most normal-traffic cases
Cold (pool miss)80–150msPool exhausted by concurrent calls, or first invocation after deploy
Timeout5000msPlugin exceeded CPU time limit — isolate is terminated
Conceptual pool usage (src/lib/plugins/sandbox/pool.ts)
// 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
}
typescript

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.

PatternWhy it's blockedError code
eval() / new Function()Dynamic code execution can escape sandboxSEC-001
process.env accessExposes host environment variablesSEC-002
require() / import()Node module access bypasses capability manifestSEC-003
fs.* / path.*Filesystem access is not available in the sandboxSEC-004
Buffer() constructorBuffer can be used to read arbitrary memory in some V8 versionsSEC-005
XMLHttpRequest / fetch (global)Network must go through velocms.network.fetch onlySEC-006

Project layout

A plugin is a small TypeScript project: a manifest.json that declares its metadata, hooks and capabilities, a src/index.ts that exports the hook handlers, a test file written against the SDK's mock, and a tsconfig. The create-velocms-plugin package on npm generates that skeleton. It's listed here so the layout is documented, but there's nowhere to install the result on a VeloCMS site today, so there's no reason to run it yet.

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.

External API call pattern
// 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),
    },
  };
}
typescript

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.

Persistent counter pattern
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 });
  }
}
typescript

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.

Post content modification pattern (beforePostPublish)
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 },
  };
}
typescript

Logging and testing

The isolated-vm sandbox suppresses console.log output to prevent information leakage, so a plugin reports what it's doing through velocms.logger instead. The SDK's createMockVelocms helper turns the logger and every other API method into spies, which means a hook handler can be tested in plain Vitest by asserting on those calls, with no running VeloCMS instance involved.

Third-party submissions

There is no submission process today. The /developers pages don't accept plugins, and there's no upload form, review queue or payout flow behind them. When VeloCMS opens plugin development to outside developers, the process will be documented here first. If there's an integration your site needs, email [email protected] and tell us what it should do.

Reference

ResourcePurpose
@velocms/plugin-sdknpm package (alpha): VelocmsPluginAPI types, createMockVelocms
create-velocms-pluginnpm package: generates the plugin project layout described above
plugin-developmentIntroductory plugin article: sandbox model, hello world