diff --git a/docs/tools/self-learning.md b/docs/tools/self-learning.md
index 29c20dd679ea..ab81fdd0a202 100644
--- a/docs/tools/self-learning.md
+++ b/docs/tools/self-learning.md
@@ -277,7 +277,7 @@ result pending regardless of autonomous mode.
| `skills.workshop.autonomous.mode` | `"auto"` | Chooses capture behavior; `auto` also enables daily collection review. |
| `skills.workshop.approvalPolicy` | `"auto"` | Controls prompts for normal agent-initiated lifecycle calls. It never expands the isolated reviewer tool surface. |
| `skills.workshop.maxPending` | `50` | Caps pending and quarantined proposals per workspace. |
-| `skills.workshop.maxSkillBytes` | `40000` | Caps proposal body size in bytes. |
+| `skills.workshop.maxSkillBytes` | `40000` | Caps persisted `SKILL.md` size in bytes, including retained frontmatter. |
| `skills.workshop.allowSymlinkTargetWrites` | `false` | Allows apply through explicitly trusted workspace skill symlinks. Capture itself does not widen the trusted target list. |
See [Skills config](/tools/skills-config#workshop-skills-workshop) for ranges and
diff --git a/docs/tools/skill-workshop.md b/docs/tools/skill-workshop.md
index be6311a14e69..68f28c22e4b0 100644
--- a/docs/tools/skill-workshop.md
+++ b/docs/tools/skill-workshop.md
@@ -378,7 +378,7 @@ the proposal threshold, and troubleshooting.
| `allowSymlinkTargetWrites` | `false` | Lets apply write through workspace skill symlinks whose real target is listed in `skills.load.allowSymlinkTargets`. |
| `approvalPolicy` | `"auto"` | `"auto"` skips an additional prompt for agent-initiated `apply`, `reject`, or `quarantine` (the agent still has to call the action). `"pending"` requires approval. |
| `maxPending` | `50` | Caps pending and quarantined proposals per workspace (1-200). |
-| `maxSkillBytes` | `40000` | Caps proposal body size in bytes (1024-200000). |
+| `maxSkillBytes` | `40000` | Caps persisted `SKILL.md` size in bytes, including retained frontmatter (1024-200000). |
In `propose` and `auto` modes, an isolated run of the selected model decides whether the
completed trajectory clears the evidence-gated proposal bar. The foreground model is not prompted
@@ -474,7 +474,7 @@ proposals remain listed with a previous-workspace marker instead of disappearing
| Limit | Value |
| ------------------------------- | ---------------------------------------------------------------------------- |
| Description | 160 bytes |
-| Proposal body | `skills.workshop.maxSkillBytes` (default 40,000; hard ceiling 200,000 bytes) |
+| Persisted `SKILL.md` | `skills.workshop.maxSkillBytes` (default 40,000; hard ceiling 200,000 bytes) |
| Support files | 64 per proposal |
| Support file size | 256 KiB each, 2 MiB total |
| Pending + quarantined proposals | `skills.workshop.maxPending` per workspace (default 50) |
@@ -484,7 +484,7 @@ proposals remain listed with a previous-workspace marker instead of disappearing
| Problem | Resolution |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Skill proposal description is too large` | Shorten `description` to 160 bytes or less. |
-| `Skill proposal content is too large` | Shorten the proposal body or raise `skills.workshop.maxSkillBytes`. |
+| `Skill proposal content is too large` | Shorten the resulting skill or raise `skills.workshop.maxSkillBytes`. |
| `Target skill changed after proposal creation` | Revise the proposal against the current target, or create a new proposal. |
| `Proposal scan failed` | Inspect scanner findings, then revise or quarantine the proposal. |
| `untrusted symlink target` | Configure `skills.load.allowSymlinkTargets` and enable `skills.workshop.allowSymlinkTargetWrites` only for intentional shared skill roots. |
diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md
index e22509900601..1cd1ed3f3588 100644
--- a/docs/tools/skills-config.md
+++ b/docs/tools/skills-config.md
@@ -389,9 +389,9 @@ proposal-only permissions, and troubleshooting.
- Maximum proposal body size in bytes (allowed range: 1024-200000). Proposal
- descriptions are hard-capped at 160 bytes separately, because they appear
- in discovery and listing output.
+ Maximum persisted `SKILL.md` size in bytes, including retained frontmatter
+ (allowed range: 1024-200000). Proposal descriptions are hard-capped at 160
+ bytes separately, because they appear in discovery and listing output.
See [Skill Workshop](/tools/skill-workshop) for the proposal lifecycle, CLI
diff --git a/src/agents/tools/skill-workshop-tool-schema.ts b/src/agents/tools/skill-workshop-tool-schema.ts
index 876f854da99a..c50f92df8816 100644
--- a/src/agents/tools/skill-workshop-tool-schema.ts
+++ b/src/agents/tools/skill-workshop-tool-schema.ts
@@ -130,7 +130,7 @@ export function buildSkillWorkshopToolSchema(
proposal_content: Type.Optional(
Type.String({
description:
- "Complete final skill body for action=create or action=update, or when action=revise changes the body. Must be the full skill content ready to become the active SKILL.md — not a plan, diff, change description, or implementation notes. On revise, omit this field to preserve the current body. On update/revise, preserve all existing content except changes the user explicitly requested. Proposal frontmatter is added automatically. Keep under configured skills.workshop.maxSkillBytes; default max is 40000 bytes.",
+ "Complete final skill body for action=create or action=update, or when action=revise changes the body. Must be the full skill content ready to become the active SKILL.md — not a plan, diff, change description, or implementation notes. On revise, omit this field to preserve the current body. On update/revise, preserve all existing content except changes the user explicitly requested. Proposal frontmatter is added automatically. The persisted SKILL.md, including retained frontmatter, must fit configured skills.workshop.maxSkillBytes; default max is 40000 bytes.",
}),
),
support_files: Type.Optional(
diff --git a/src/config/types.skills.ts b/src/config/types.skills.ts
index 6c23b2d334d0..33a14aff873d 100644
--- a/src/config/types.skills.ts
+++ b/src/config/types.skills.ts
@@ -69,7 +69,7 @@ export type SkillsWorkshopConfig = {
approvalPolicy?: "pending" | "auto";
/** Maximum pending/quarantined proposals retained per workspace. */
maxPending?: number;
- /** Maximum generated skill proposal size in bytes. */
+ /** Maximum persisted SKILL.md size in bytes, including retained frontmatter. */
maxSkillBytes?: number;
};
diff --git a/src/skills/workshop/proposal-draft.test.ts b/src/skills/workshop/proposal-draft.test.ts
index b4180cba5269..bc7b94a21d3c 100644
--- a/src/skills/workshop/proposal-draft.test.ts
+++ b/src/skills/workshop/proposal-draft.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { stripProposalFrontmatterForSkill } from "./frontmatter.js";
import {
nextProposalVersion,
prepareSkillProposalDraft,
@@ -52,7 +53,9 @@ describe("Skill Workshop proposal draft preparation", () => {
expect(oversized).toMatchObject({
ok: false,
error: {
- message: "Skill proposal content is too large (5 bytes, max 4).",
+ message: expect.stringMatching(
+ /^Skill proposal content is too large \(\d+ bytes, max 4\)\.$/,
+ ),
},
});
@@ -77,6 +80,39 @@ describe("Skill Workshop proposal draft preparation", () => {
});
});
+ it("measures the canonical persisted skill instead of the submitted body", () => {
+ const maxSkillBytes = 1024;
+ const input = {
+ name: "boundary-skill",
+ description: "Boundary skill",
+ content: "x".repeat(maxSkillBytes),
+ date: "2026-08-14T00:00:00.000Z",
+ };
+ const prepared = prepareSkillProposalDraft({
+ ...input,
+ maxSkillBytes,
+ });
+
+ expect(prepared).toMatchObject({
+ ok: false,
+ error: {
+ message: expect.stringMatching(
+ /^Skill proposal content is too large \(1\d{3} bytes, max 1024\)\.$/,
+ ),
+ },
+ });
+
+ const roomy = prepareSkillProposalDraft({ ...input, maxSkillBytes: 2048 });
+ if (!roomy.ok) {
+ throw roomy.error.cause;
+ }
+ const persistedBytes = Buffer.byteLength(
+ stripProposalFrontmatterForSkill(roomy.value.content),
+ "utf8",
+ );
+ expect(prepareSkillProposalDraft({ ...input, maxSkillBytes: persistedBytes }).ok).toBe(true);
+ });
+
it("preserves version and UTF-8 description behavior", () => {
expect(nextProposalVersion("v1")).toBe("v2");
expect(nextProposalVersion("invalid")).toBe("v2");
diff --git a/src/skills/workshop/proposal-draft.ts b/src/skills/workshop/proposal-draft.ts
index 56dfecffb6d1..559214e9edac 100644
--- a/src/skills/workshop/proposal-draft.ts
+++ b/src/skills/workshop/proposal-draft.ts
@@ -7,7 +7,7 @@ import {
MAX_WORKSPACE_SKILL_SUPPORT_FILE_BYTES,
normalizeWorkspaceSkillSupportPath,
} from "../lifecycle/workspace-skill-write.js";
-import { renderProposalMarkdown } from "./frontmatter.js";
+import { renderProposalMarkdown, stripProposalFrontmatterForSkill } from "./frontmatter.js";
import { assertProposalContainsNoLiteralSecrets, scanProposalBundle } from "./proposal-scan.js";
import {
hashSkillProposalContent,
@@ -54,7 +54,6 @@ export function prepareSkillProposalDraft(input: {
}): Result {
try {
assertProposalDescriptionWithinLimit(input.description);
- assertProposalContentWithinLimit(input.content, input.maxSkillBytes);
const supportFiles = prepareSkillProposalSupportFiles(input.supportFiles);
const content = renderProposalMarkdown({
name: input.name,
@@ -64,6 +63,10 @@ export function prepareSkillProposalDraft(input: {
version: input.version,
date: input.date,
});
+ assertProposalContentWithinLimit(
+ stripProposalFrontmatterForSkill(content),
+ input.maxSkillBytes,
+ );
const goal = normalizeOptionalString(input.goal);
const evidence = normalizeOptionalString(input.evidence);
const scan = scanProposalBundle(content, supportFiles, [