diff --git a/.changeset/before-save-rejection.md b/.changeset/before-save-rejection.md
new file mode 100644
index 0000000000..00c1e2933f
--- /dev/null
+++ b/.changeset/before-save-rejection.md
@@ -0,0 +1,5 @@
+---
+"emdash": minor
+---
+
+Adds `ContentSaveRejectedError` so a `content:beforeSave` hook can reject a save with a message for the editor. Throwing it from a trusted plugin makes the content API respond with a structured `SAVE_REJECTED` error (HTTP 422) that carries the message, and the admin shows it in the save and autosave toasts. Any other exception thrown by the hook still cancels the save, but now returns a generic `CONTENT_HOOK_ERROR` response instead of escaping as an unstructured 500 that could leak exception details. A plugin running in the sandbox cannot reject a save yet; the save proceeds as before, and the sandbox log states that a sandboxed plugin cannot cancel a save. Moving the plugin into `plugins: []` runs it in the host process, where rejection works.
diff --git a/docs/src/content/docs/plugins/creating-plugins/hooks.mdx b/docs/src/content/docs/plugins/creating-plugins/hooks.mdx
index 6573fccc4a..7d38444909 100644
--- a/docs/src/content/docs/plugins/creating-plugins/hooks.mdx
+++ b/docs/src/content/docs/plugins/creating-plugins/hooks.mdx
@@ -136,15 +136,17 @@ Run during create, update, and delete operations on site content.
### `content:beforeSave`
-Runs before content is saved. Return modified content, or `void` to leave it unchanged. Throw to cancel.
+Runs before content is saved. Return modified content, or `void` to leave it unchanged.
+
+
+
+To cancel the save from a plugin running in the host process, throw `ContentSaveRejectedError` (exported from `emdash`): the API responds with a `SAVE_REJECTED` error that carries your message, and the admin shows it to the editor. Any other thrown error also cancels the save, but the response replaces its message with a generic one.
```typescript
"content:beforeSave": async (event, ctx) => {
- const { content, collection } = event;
-
- if (collection === "posts" && !content.title) {
- throw new Error("Posts require a title");
- }
+ const { content } = event;
if (typeof content.slug === "string") {
content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
diff --git a/docs/src/content/docs/reference/hooks.mdx b/docs/src/content/docs/reference/hooks.mdx
index b984b3ea4e..933e35043b 100644
--- a/docs/src/content/docs/reference/hooks.mdx
+++ b/docs/src/content/docs/reference/hooks.mdx
@@ -43,7 +43,7 @@ The following table lists every hook, what triggers it, what it can modify, and
### `content:beforeSave`
-Runs before content is saved to the database. Use to validate, transform, or enrich content.
+Runs before content is saved to the database. Use to validate, transform, or enrich content. To reject a save, throw `ContentSaveRejectedError` (exported from `emdash`): the API responds with a `SAVE_REJECTED` error that carries the message, and the admin shows it to the editor. Any other thrown error also cancels the save, but the response replaces its message with a generic one. A plugin running in the sandbox cannot reject a save: the sandbox logs the thrown error and the save continues.
```ts
import { definePlugin } from "emdash";
diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx
index 4f9d239d57..597fa7058c 100644
--- a/docs/src/content/docs/reference/rest-api.mdx
+++ b/docs/src/content/docs/reference/rest-api.mdx
@@ -1135,6 +1135,8 @@ POST /_emdash/api/admin/plugins/:id/disable
| `CONTENT_LIST_ERROR` | 500 | Failed to list content |
| `CONTENT_CREATE_ERROR` | 500 | Failed to create content |
| `CONTENT_UPDATE_ERROR` | 500 | Failed to update content |
+| `SAVE_REJECTED` | 422 | Save rejected by a plugin hook |
+| `CONTENT_HOOK_ERROR` | 500 | Plugin hook failed during save |
| `CONTENT_DELETE_ERROR` | 500 | Failed to delete content |
| `MEDIA_LIST_ERROR` | 500 | Failed to list media |
| `MEDIA_CREATE_ERROR` | 500 | Failed to create media |
diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts
index 0b0d8784c6..b1406fab52 100644
--- a/packages/core/src/api/errors.ts
+++ b/packages/core/src/api/errors.ts
@@ -28,6 +28,8 @@ export const ErrorCode = {
// Content
CONTENT_CREATE_ERROR: "CONTENT_CREATE_ERROR",
CONTENT_UPDATE_ERROR: "CONTENT_UPDATE_ERROR",
+ SAVE_REJECTED: "SAVE_REJECTED",
+ CONTENT_HOOK_ERROR: "CONTENT_HOOK_ERROR",
CONTENT_DELETE_ERROR: "CONTENT_DELETE_ERROR",
CONTENT_LIST_ERROR: "CONTENT_LIST_ERROR",
CONTENT_GET_ERROR: "CONTENT_GET_ERROR",
@@ -485,6 +487,7 @@ export function mapErrorStatus(code: string | undefined): number {
return 410;
// 422 Unprocessable Entity
+ case ErrorCode.SAVE_REJECTED:
case ErrorCode.CHECKSUM_MISMATCH:
case ErrorCode.INVALID_BUNDLE:
case ErrorCode.BUNDLE_EXTRACT_FAILED:
diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts
index 4565ac6b98..319f546707 100644
--- a/packages/core/src/emdash-runtime.ts
+++ b/packages/core/src/emdash-runtime.ts
@@ -13,6 +13,7 @@ import { Kysely, type Dialect } from "kysely";
import virtualConfig from "virtual:emdash/config";
import { z } from "zod";
+import { ErrorCode } from "./api/errors.js";
import { assertMediaUsageActivationWriteAllowed } from "./api/media-usage-write-fence.js";
import { validateRev } from "./api/rev.js";
import type {
@@ -216,6 +217,7 @@ import {
type RouteCallerInput,
type RouteMeta,
} from "./plugins/routes.js";
+import { isContentSaveRejection } from "./plugins/save-rejection.js";
import type { CronScheduler } from "./plugins/scheduler/types.js";
import { PluginStateRepository } from "./plugins/state.js";
import { syncDeclaredStorageIndexes } from "./plugins/storage-indexes.js";
@@ -423,6 +425,28 @@ export interface EmDashRuntimeParts {
pipelineRef: { current: HookPipeline };
}
+/**
+ * A `ContentSaveRejectedError` carries a message the plugin wrote for the
+ * editor; every other exception stays internal and is replaced by a generic
+ * message so hook internals cannot leak through the API.
+ */
+function beforeSaveFailure(error: unknown) {
+ if (isContentSaveRejection(error)) {
+ return {
+ success: false as const,
+ error: { code: ErrorCode.SAVE_REJECTED, message: error.message },
+ };
+ }
+ console.error("EmDash: content:beforeSave hook failed:", error);
+ return {
+ success: false as const,
+ error: {
+ code: ErrorCode.CONTENT_HOOK_ERROR,
+ message: "A plugin hook failed while saving content",
+ },
+ };
+}
+
/**
* Convert a ContentItem to Record for hook consumption.
* Hooks receive the full item as a flat record.
@@ -2890,8 +2914,12 @@ export class EmDashRuntime {
// Run beforeSave hooks (trusted plugins)
let processedData = body.data;
if (this.hooks.hasHooks("content:beforeSave")) {
- const hookResult = await this.hooks.runContentBeforeSave(body.data, collection, true);
- processedData = hookResult.content;
+ try {
+ const hookResult = await this.hooks.runContentBeforeSave(body.data, collection, true);
+ processedData = hookResult.content;
+ } catch (error) {
+ return beforeSaveFailure(error);
+ }
}
// Run beforeSave hooks (sandboxed plugins)
@@ -2986,12 +3014,16 @@ export class EmDashRuntime {
let processedData = bodyWithoutRev.data;
if (bodyWithoutRev.data) {
if (this.hooks.hasHooks("content:beforeSave")) {
- const hookResult = await this.hooks.runContentBeforeSave(
- bodyWithoutRev.data,
- collection,
- false,
- );
- processedData = hookResult.content;
+ try {
+ const hookResult = await this.hooks.runContentBeforeSave(
+ bodyWithoutRev.data,
+ collection,
+ false,
+ );
+ processedData = hookResult.content;
+ } catch (error) {
+ return beforeSaveFailure(error);
+ }
}
// Run sandboxed beforeSave hooks
@@ -4056,7 +4088,10 @@ export class EmDashRuntime {
result = record;
}
} catch (error) {
- console.error(`EmDash: Sandboxed plugin ${id} beforeSave hook error:`, error);
+ console.error(
+ `EmDash: Sandboxed plugin ${id} beforeSave hook threw; a sandboxed plugin cannot cancel a save, so the save continues:`,
+ error,
+ );
}
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 4936d04156..2f6e17031e 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -243,6 +243,8 @@ export {
PluginManager,
createPluginManager,
PluginRouteError,
+ ContentSaveRejectedError,
+ isContentSaveRejection,
// Scheduler (Node timer heartbeat — used by virtual:emdash/scheduler)
NodeCronScheduler,
// Sandbox
diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts
index fdb4816d00..4dcaacd03c 100644
--- a/packages/core/src/plugins/index.ts
+++ b/packages/core/src/plugins/index.ts
@@ -44,6 +44,7 @@ export type { PluginContextFactoryOptions } from "./context.js";
// Hooks
export { HookPipeline, createHookPipeline } from "./hooks.js";
export type { HookResult } from "./hooks.js";
+export { ContentSaveRejectedError, isContentSaveRejection } from "./save-rejection.js";
// Email pipeline
export { EmailPipeline, EmailNotConfiguredError, EmailRecursionError } from "./email.js";
diff --git a/packages/core/src/plugins/save-rejection.ts b/packages/core/src/plugins/save-rejection.ts
new file mode 100644
index 0000000000..d6ac7f4006
--- /dev/null
+++ b/packages/core/src/plugins/save-rejection.ts
@@ -0,0 +1,20 @@
+/**
+ * Thrown by a `content:beforeSave` hook to reject a save with a message
+ * that is safe to show to the editor. The runtime converts it into a
+ * structured `SAVE_REJECTED` API error; any other exception thrown by the
+ * hook cancels the save with a generic error that hides the exception
+ * message.
+ */
+export class ContentSaveRejectedError extends Error {
+ override readonly name = "ContentSaveRejectedError";
+}
+
+/**
+ * Matches by name as well as by prototype: bundlers can duplicate this
+ * module across SSR chunks, and an `instanceof` against the wrong copy of
+ * the class would misreport a rejection as a plugin crash.
+ */
+export function isContentSaveRejection(error: unknown): error is ContentSaveRejectedError {
+ if (error instanceof ContentSaveRejectedError) return true;
+ return error instanceof Error && error.name === "ContentSaveRejectedError";
+}
diff --git a/packages/core/tests/integration/runtime/before-save-rejection.test.ts b/packages/core/tests/integration/runtime/before-save-rejection.test.ts
new file mode 100644
index 0000000000..724a445dea
--- /dev/null
+++ b/packages/core/tests/integration/runtime/before-save-rejection.test.ts
@@ -0,0 +1,148 @@
+import { randomUUID } from "node:crypto";
+
+import Database from "better-sqlite3";
+import { SqliteDialect } from "kysely";
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+
+import { mapErrorStatus } from "../../../src/api/errors.js";
+import { ContentRepository } from "../../../src/database/repositories/content.js";
+import { EmDashRuntime } from "../../../src/emdash-runtime.js";
+import type { RuntimeDependencies } from "../../../src/emdash-runtime.js";
+import { definePlugin } from "../../../src/plugins/define-plugin.js";
+import { ContentSaveRejectedError } from "../../../src/plugins/save-rejection.js";
+import type { ContentBeforeSaveHandler } from "../../../src/plugins/types.js";
+import { SchemaRegistry } from "../../../src/schema/registry.js";
+
+function createDeps(
+ sqlite: Database.Database,
+ handler: ContentBeforeSaveHandler,
+): RuntimeDependencies {
+ return {
+ config: {
+ database: {
+ entrypoint: `test-before-save-rejection-${randomUUID()}`,
+ config: {},
+ type: "sqlite",
+ },
+ },
+ plugins: [
+ definePlugin({
+ id: "editorial-gate",
+ version: "1.0.0",
+ capabilities: ["content:read", "content:write"],
+ hooks: {
+ "content:beforeSave": { handler },
+ },
+ }),
+ ],
+ createDialect: () => new SqliteDialect({ database: sqlite }),
+ createStorage: null,
+ sandboxEnabled: false,
+ sandboxedPluginEntries: [],
+ createSandboxRunner: null,
+ };
+}
+
+describe("content:beforeSave cancellation", () => {
+ let runtime: EmDashRuntime;
+ let repo: ContentRepository;
+
+ async function boot(handler: ContentBeforeSaveHandler) {
+ const sqlite = new Database(":memory:");
+ runtime = await EmDashRuntime.create(createDeps(sqlite, handler));
+ const registry = new SchemaRegistry(runtime.db);
+ await registry.createCollection({ slug: "post", label: "Posts", labelSingular: "Post" });
+ await registry.createField("post", { slug: "title", label: "Title", type: "string" });
+ repo = new ContentRepository(runtime.db);
+ }
+
+ afterEach(async () => {
+ await runtime?.stopCron();
+ });
+
+ describe("ContentSaveRejectedError", () => {
+ beforeEach(() =>
+ boot(async () => {
+ throw new ContentSaveRejectedError("Posts need a summary");
+ }),
+ );
+
+ it("returns SAVE_REJECTED with the plugin message on create", async () => {
+ const result = await runtime.handleContentCreate("post", { data: { title: "Hi" } });
+
+ expect(result).toEqual({
+ success: false,
+ error: { code: "SAVE_REJECTED", message: "Posts need a summary" },
+ });
+ if (result.success) return;
+ expect(mapErrorStatus(result.error.code)).toBe(422);
+ const rows = await repo.findMany("post");
+ expect(rows.items).toHaveLength(0);
+ });
+
+ it("returns SAVE_REJECTED with the plugin message on update", async () => {
+ const item = await repo.create({ type: "post", data: { title: "Original" } });
+
+ const result = await runtime.handleContentUpdate("post", item.id, {
+ data: { title: "Changed" },
+ });
+
+ expect(result).toEqual({
+ success: false,
+ error: { code: "SAVE_REJECTED", message: "Posts need a summary" },
+ });
+ if (result.success) return;
+ expect(mapErrorStatus(result.error.code)).toBe(422);
+ const kept = await repo.findById("post", item.id);
+ expect(kept?.data.title).toBe("Original");
+ });
+ });
+
+ describe("unexpected hook exception", () => {
+ beforeEach(() =>
+ boot(async () => {
+ throw new Error("secret internal detail");
+ }),
+ );
+
+ it("returns a generic error that hides the exception message on create", async () => {
+ const result = await runtime.handleContentCreate("post", { data: { title: "Hi" } });
+
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.code).toBe("CONTENT_HOOK_ERROR");
+ expect(result.error.message).not.toContain("secret internal detail");
+ });
+
+ it("returns a generic error that hides the exception message on update", async () => {
+ const item = await repo.create({ type: "post", data: { title: "Original" } });
+
+ const result = await runtime.handleContentUpdate("post", item.id, {
+ data: { title: "Changed" },
+ });
+
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.code).toBe("CONTENT_HOOK_ERROR");
+ expect(result.error.message).not.toContain("secret internal detail");
+ const kept = await repo.findById("post", item.id);
+ expect(kept?.data.title).toBe("Original");
+ });
+ });
+
+ describe("hook that does not throw", () => {
+ beforeEach(() =>
+ boot(async (event) => {
+ return { ...event.content, title: `${event.content.title as string}!` };
+ }),
+ );
+
+ it("still applies the hook's content changes", async () => {
+ const result = await runtime.handleContentCreate("post", { data: { title: "Hi" } });
+
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ expect(result.data.item.data.title).toBe("Hi!");
+ });
+ });
+});
diff --git a/packages/core/tests/integration/runtime/sandboxed-before-save-throw.test.ts b/packages/core/tests/integration/runtime/sandboxed-before-save-throw.test.ts
new file mode 100644
index 0000000000..a34b6c0c94
--- /dev/null
+++ b/packages/core/tests/integration/runtime/sandboxed-before-save-throw.test.ts
@@ -0,0 +1,78 @@
+import { randomUUID } from "node:crypto";
+
+import Database from "better-sqlite3";
+import { SqliteDialect } from "kysely";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { ContentRepository } from "../../../src/database/repositories/content.js";
+import { EmDashRuntime, type RuntimeDependencies } from "../../../src/emdash-runtime.js";
+import { ContentSaveRejectedError } from "../../../src/plugins/save-rejection.js";
+import { SchemaRegistry } from "../../../src/schema/registry.js";
+
+function createDeps(sqlite: Database.Database): RuntimeDependencies {
+ const runner = {
+ isAvailable: () => true,
+ isHealthy: () => true,
+ load: vi.fn().mockResolvedValue({
+ id: "editorial-gate:1.0.0",
+ invokeHook: vi.fn().mockRejectedValue(new ContentSaveRejectedError("Posts need a summary")),
+ invokeRoute: vi.fn(),
+ terminate: vi.fn(),
+ }),
+ setEmailSend: vi.fn(),
+ terminateAll: vi.fn(),
+ };
+ return {
+ config: {
+ database: {
+ entrypoint: `test-sandboxed-before-save-throw-${randomUUID()}`,
+ config: {},
+ type: "sqlite",
+ },
+ },
+ plugins: [],
+ createDialect: () => new SqliteDialect({ database: sqlite }),
+ createStorage: null,
+ sandboxEnabled: true,
+ sandboxedPluginEntries: [
+ {
+ id: "editorial-gate",
+ version: "1.0.0",
+ options: {},
+ code: "",
+ capabilities: ["content:read", "content:write"],
+ allowedHosts: [],
+ storage: {},
+ },
+ ],
+ // eslint-disable-next-line typescript/no-explicit-any -- test fake matches the SandboxRunner shape sandboxed-plugin-route-meta.test.ts uses
+ createSandboxRunner: (() => runner) as any,
+ };
+}
+
+describe("content:beforeSave thrown from a sandboxed plugin", () => {
+ let runtime: EmDashRuntime;
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ await runtime?.stopCron();
+ });
+
+ it("saves anyway and logs that a sandboxed plugin cannot cancel a save", async () => {
+ runtime = await EmDashRuntime.create(createDeps(new Database(":memory:")));
+ const registry = new SchemaRegistry(runtime.db);
+ await registry.createCollection({ slug: "post", label: "Posts", labelSingular: "Post" });
+ await registry.createField("post", { slug: "title", label: "Title", type: "string" });
+ const logged = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ const result = await runtime.handleContentCreate("post", { data: { title: "Hi" } });
+
+ expect(result.success).toBe(true);
+ const rows = await new ContentRepository(runtime.db).findMany("post");
+ expect(rows.items).toHaveLength(1);
+ expect(logged).toHaveBeenCalledWith(
+ expect.stringContaining("cannot cancel a save"),
+ expect.any(ContentSaveRejectedError),
+ );
+ });
+});
diff --git a/skills/creating-plugins/references/hooks.md b/skills/creating-plugins/references/hooks.md
index 2a1532d8d5..f6d51600f5 100644
--- a/skills/creating-plugins/references/hooks.md
+++ b/skills/creating-plugins/references/hooks.md
@@ -96,15 +96,11 @@ Returns: `void`
### `content:beforeSave`
-Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
+Runs before save. Return modified content, or void to keep it unchanged. A plugin running in the sandbox cannot cancel a save: the sandbox logs the thrown error and the save continues. From the host process — a native plugin, or a sandboxed plugin moved to `plugins: []` — throw `ContentSaveRejectedError` (exported from `emdash`) to cancel with a message the admin shows to the editor; any other thrown error cancels with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
- const { content, collection, isNew } = event;
-
- if (collection === "posts" && !content.title) {
- throw new Error("Posts require a title");
- }
+ const { content } = event;
// Transform
if (content.slug) {
diff --git a/templates/blank/.agents/skills/creating-plugins/references/hooks.md b/templates/blank/.agents/skills/creating-plugins/references/hooks.md
index 2a1532d8d5..f6d51600f5 100644
--- a/templates/blank/.agents/skills/creating-plugins/references/hooks.md
+++ b/templates/blank/.agents/skills/creating-plugins/references/hooks.md
@@ -96,15 +96,11 @@ Returns: `void`
### `content:beforeSave`
-Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
+Runs before save. Return modified content, or void to keep it unchanged. A plugin running in the sandbox cannot cancel a save: the sandbox logs the thrown error and the save continues. From the host process — a native plugin, or a sandboxed plugin moved to `plugins: []` — throw `ContentSaveRejectedError` (exported from `emdash`) to cancel with a message the admin shows to the editor; any other thrown error cancels with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
- const { content, collection, isNew } = event;
-
- if (collection === "posts" && !content.title) {
- throw new Error("Posts require a title");
- }
+ const { content } = event;
// Transform
if (content.slug) {
diff --git a/templates/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md b/templates/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md
index 2a1532d8d5..f6d51600f5 100644
--- a/templates/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md
+++ b/templates/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md
@@ -96,15 +96,11 @@ Returns: `void`
### `content:beforeSave`
-Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
+Runs before save. Return modified content, or void to keep it unchanged. A plugin running in the sandbox cannot cancel a save: the sandbox logs the thrown error and the save continues. From the host process — a native plugin, or a sandboxed plugin moved to `plugins: []` — throw `ContentSaveRejectedError` (exported from `emdash`) to cancel with a message the admin shows to the editor; any other thrown error cancels with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
- const { content, collection, isNew } = event;
-
- if (collection === "posts" && !content.title) {
- throw new Error("Posts require a title");
- }
+ const { content } = event;
// Transform
if (content.slug) {
diff --git a/templates/blog/.agents/skills/creating-plugins/references/hooks.md b/templates/blog/.agents/skills/creating-plugins/references/hooks.md
index 2a1532d8d5..f6d51600f5 100644
--- a/templates/blog/.agents/skills/creating-plugins/references/hooks.md
+++ b/templates/blog/.agents/skills/creating-plugins/references/hooks.md
@@ -96,15 +96,11 @@ Returns: `void`
### `content:beforeSave`
-Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
+Runs before save. Return modified content, or void to keep it unchanged. A plugin running in the sandbox cannot cancel a save: the sandbox logs the thrown error and the save continues. From the host process — a native plugin, or a sandboxed plugin moved to `plugins: []` — throw `ContentSaveRejectedError` (exported from `emdash`) to cancel with a message the admin shows to the editor; any other thrown error cancels with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
- const { content, collection, isNew } = event;
-
- if (collection === "posts" && !content.title) {
- throw new Error("Posts require a title");
- }
+ const { content } = event;
// Transform
if (content.slug) {
diff --git a/templates/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md b/templates/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md
index 2a1532d8d5..f6d51600f5 100644
--- a/templates/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md
+++ b/templates/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md
@@ -96,15 +96,11 @@ Returns: `void`
### `content:beforeSave`
-Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
+Runs before save. Return modified content, or void to keep it unchanged. A plugin running in the sandbox cannot cancel a save: the sandbox logs the thrown error and the save continues. From the host process — a native plugin, or a sandboxed plugin moved to `plugins: []` — throw `ContentSaveRejectedError` (exported from `emdash`) to cancel with a message the admin shows to the editor; any other thrown error cancels with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
- const { content, collection, isNew } = event;
-
- if (collection === "posts" && !content.title) {
- throw new Error("Posts require a title");
- }
+ const { content } = event;
// Transform
if (content.slug) {
diff --git a/templates/marketing/.agents/skills/creating-plugins/references/hooks.md b/templates/marketing/.agents/skills/creating-plugins/references/hooks.md
index 2a1532d8d5..f6d51600f5 100644
--- a/templates/marketing/.agents/skills/creating-plugins/references/hooks.md
+++ b/templates/marketing/.agents/skills/creating-plugins/references/hooks.md
@@ -96,15 +96,11 @@ Returns: `void`
### `content:beforeSave`
-Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
+Runs before save. Return modified content, or void to keep it unchanged. A plugin running in the sandbox cannot cancel a save: the sandbox logs the thrown error and the save continues. From the host process — a native plugin, or a sandboxed plugin moved to `plugins: []` — throw `ContentSaveRejectedError` (exported from `emdash`) to cancel with a message the admin shows to the editor; any other thrown error cancels with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
- const { content, collection, isNew } = event;
-
- if (collection === "posts" && !content.title) {
- throw new Error("Posts require a title");
- }
+ const { content } = event;
// Transform
if (content.slug) {
diff --git a/templates/portfolio-cloudflare/.agents/skills/creating-plugins/references/hooks.md b/templates/portfolio-cloudflare/.agents/skills/creating-plugins/references/hooks.md
index 2a1532d8d5..f6d51600f5 100644
--- a/templates/portfolio-cloudflare/.agents/skills/creating-plugins/references/hooks.md
+++ b/templates/portfolio-cloudflare/.agents/skills/creating-plugins/references/hooks.md
@@ -96,15 +96,11 @@ Returns: `void`
### `content:beforeSave`
-Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
+Runs before save. Return modified content, or void to keep it unchanged. A plugin running in the sandbox cannot cancel a save: the sandbox logs the thrown error and the save continues. From the host process — a native plugin, or a sandboxed plugin moved to `plugins: []` — throw `ContentSaveRejectedError` (exported from `emdash`) to cancel with a message the admin shows to the editor; any other thrown error cancels with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
- const { content, collection, isNew } = event;
-
- if (collection === "posts" && !content.title) {
- throw new Error("Posts require a title");
- }
+ const { content } = event;
// Transform
if (content.slug) {
diff --git a/templates/portfolio/.agents/skills/creating-plugins/references/hooks.md b/templates/portfolio/.agents/skills/creating-plugins/references/hooks.md
index 2a1532d8d5..f6d51600f5 100644
--- a/templates/portfolio/.agents/skills/creating-plugins/references/hooks.md
+++ b/templates/portfolio/.agents/skills/creating-plugins/references/hooks.md
@@ -96,15 +96,11 @@ Returns: `void`
### `content:beforeSave`
-Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
+Runs before save. Return modified content, or void to keep it unchanged. A plugin running in the sandbox cannot cancel a save: the sandbox logs the thrown error and the save continues. From the host process — a native plugin, or a sandboxed plugin moved to `plugins: []` — throw `ContentSaveRejectedError` (exported from `emdash`) to cancel with a message the admin shows to the editor; any other thrown error cancels with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
- const { content, collection, isNew } = event;
-
- if (collection === "posts" && !content.title) {
- throw new Error("Posts require a title");
- }
+ const { content } = event;
// Transform
if (content.slug) {
diff --git a/templates/starter-cloudflare/.agents/skills/creating-plugins/references/hooks.md b/templates/starter-cloudflare/.agents/skills/creating-plugins/references/hooks.md
index 2a1532d8d5..f6d51600f5 100644
--- a/templates/starter-cloudflare/.agents/skills/creating-plugins/references/hooks.md
+++ b/templates/starter-cloudflare/.agents/skills/creating-plugins/references/hooks.md
@@ -96,15 +96,11 @@ Returns: `void`
### `content:beforeSave`
-Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
+Runs before save. Return modified content, or void to keep it unchanged. A plugin running in the sandbox cannot cancel a save: the sandbox logs the thrown error and the save continues. From the host process — a native plugin, or a sandboxed plugin moved to `plugins: []` — throw `ContentSaveRejectedError` (exported from `emdash`) to cancel with a message the admin shows to the editor; any other thrown error cancels with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
- const { content, collection, isNew } = event;
-
- if (collection === "posts" && !content.title) {
- throw new Error("Posts require a title");
- }
+ const { content } = event;
// Transform
if (content.slug) {
diff --git a/templates/starter/.agents/skills/creating-plugins/references/hooks.md b/templates/starter/.agents/skills/creating-plugins/references/hooks.md
index 2a1532d8d5..f6d51600f5 100644
--- a/templates/starter/.agents/skills/creating-plugins/references/hooks.md
+++ b/templates/starter/.agents/skills/creating-plugins/references/hooks.md
@@ -96,15 +96,11 @@ Returns: `void`
### `content:beforeSave`
-Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
+Runs before save. Return modified content, or void to keep it unchanged. A plugin running in the sandbox cannot cancel a save: the sandbox logs the thrown error and the save continues. From the host process — a native plugin, or a sandboxed plugin moved to `plugins: []` — throw `ContentSaveRejectedError` (exported from `emdash`) to cancel with a message the admin shows to the editor; any other thrown error cancels with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
- const { content, collection, isNew } = event;
-
- if (collection === "posts" && !content.title) {
- throw new Error("Posts require a title");
- }
+ const { content } = event;
// Transform
if (content.slug) {