diff --git a/packages/shared/src/skills.ts b/packages/shared/src/skills.ts
index 540b92eba5..08be3205ae 100644
--- a/packages/shared/src/skills.ts
+++ b/packages/shared/src/skills.ts
@@ -11,6 +11,12 @@ export interface SkillInfo {
editable: boolean;
/** Size of SKILL.md in bytes (context-cost signal). */
skillMdBytes: number;
+ /**
+ * Frontmatter `disable-model-invocation: true`: the agent never invokes the
+ * skill on its own — only an explicit user invocation (slash command or
+ * composer skill tag) runs it.
+ */
+ disableModelInvocation?: boolean;
}
export interface SkillFileEntry {
@@ -44,13 +50,18 @@ export interface ExportedSkill {
* sandbox, so it must not drift between hosts.
*/
export function serializeSkillMarkdown(
- meta: { name: string; description: string },
+ meta: {
+ name: string;
+ description: string;
+ disableModelInvocation?: boolean;
+ },
body: string,
): string {
const frontmatter = [
"---",
`name: ${serializeSkillScalar(meta.name)}`,
`description: ${serializeSkillScalar(meta.description)}`,
+ ...(meta.disableModelInvocation ? ["disable-model-invocation: true"] : []),
"---",
].join("\n");
diff --git a/packages/ui/src/features/skills/SkillCard.tsx b/packages/ui/src/features/skills/SkillCard.tsx
index 5f896404a9..f3cd49f79f 100644
--- a/packages/ui/src/features/skills/SkillCard.tsx
+++ b/packages/ui/src/features/skills/SkillCard.tsx
@@ -92,6 +92,13 @@ export function SkillCard({
{skill.repoName}
)}
+ {skill.disableModelInvocation && (
+
+
+ Manual
+
+
+ )}
>
}
/>
diff --git a/packages/ui/src/features/skills/SkillDetailPanel.tsx b/packages/ui/src/features/skills/SkillDetailPanel.tsx
index 6be4fd58e3..0011942ee8 100644
--- a/packages/ui/src/features/skills/SkillDetailPanel.tsx
+++ b/packages/ui/src/features/skills/SkillDetailPanel.tsx
@@ -3,6 +3,7 @@ import {
DownloadSimple,
FilePlus,
Folder,
+ HandTap,
LockSimple,
PencilSimple,
Trash,
@@ -287,6 +288,14 @@ export function SkillDetailPanel({
Read-only
)}
+ {skill.disableModelInvocation && (
+
+
+
+ Manual-only
+
+
+ )}
{skill.source === "codex" && (
+
+
+
+ Manual invocation only
+
+
+ The agent won't use this skill on its own — only when you invoke
+ it
+
+
+
+
diff --git a/packages/workspace-server/src/services/skills/parse-skill-frontmatter.test.ts b/packages/workspace-server/src/services/skills/parse-skill-frontmatter.test.ts
index 43b336bf51..448d2eab04 100644
--- a/packages/workspace-server/src/services/skills/parse-skill-frontmatter.test.ts
+++ b/packages/workspace-server/src/services/skills/parse-skill-frontmatter.test.ts
@@ -1,5 +1,63 @@
import { describe, expect, it } from "vitest";
-import { parseSkillDependencies } from "./parse-skill-frontmatter";
+import {
+ parseSkillDependencies,
+ parseSkillFrontmatter,
+} from "./parse-skill-frontmatter";
+
+describe("parseSkillFrontmatter disable-model-invocation", () => {
+ it.each([
+ ["absent", `---\nname: a\ndescription: d\n---\nbody`, false],
+ [
+ "true",
+ `---\nname: a\ndescription: d\ndisable-model-invocation: true\n---\nbody`,
+ true,
+ ],
+ [
+ "capitalized True",
+ `---\nname: a\ndescription: d\ndisable-model-invocation: True\n---\nbody`,
+ true,
+ ],
+ [
+ "quoted true",
+ `---\nname: a\ndescription: d\ndisable-model-invocation: "true"\n---\nbody`,
+ true,
+ ],
+ [
+ "false",
+ `---\nname: a\ndescription: d\ndisable-model-invocation: false\n---\nbody`,
+ false,
+ ],
+ [
+ "non-boolean value",
+ `---\nname: a\ndescription: d\ndisable-model-invocation: maybe\n---\nbody`,
+ false,
+ ],
+ [
+ "true with trailing comment",
+ `---\nname: a\ndescription: d\ndisable-model-invocation: true # manual only\n---\nbody`,
+ true,
+ ],
+ [
+ "quoted true with trailing comment",
+ `---\nname: a\ndescription: d\ndisable-model-invocation: "true" # manual only\n---\nbody`,
+ true,
+ ],
+ [
+ "false with trailing comment",
+ `---\nname: a\ndescription: d\ndisable-model-invocation: false # keep automatic\n---\nbody`,
+ false,
+ ],
+ [
+ "quoted string that only starts with true",
+ `---\nname: a\ndescription: d\ndisable-model-invocation: "true # manual only"\n---\nbody`,
+ false,
+ ],
+ ])("parses %s", (_label, content, expected) => {
+ expect(parseSkillFrontmatter(content)?.disableModelInvocation).toBe(
+ expected,
+ );
+ });
+});
describe("parseSkillDependencies", () => {
it.each([
diff --git a/packages/workspace-server/src/services/skills/parse-skill-frontmatter.ts b/packages/workspace-server/src/services/skills/parse-skill-frontmatter.ts
index 0704a3cb0a..caebb4c743 100644
--- a/packages/workspace-server/src/services/skills/parse-skill-frontmatter.ts
+++ b/packages/workspace-server/src/services/skills/parse-skill-frontmatter.ts
@@ -1,15 +1,17 @@
/**
* Parses YAML frontmatter from a SKILL.md file.
- * Extracts `name` and `description` fields.
+ * Extracts `name`, `description`, and `disable-model-invocation` fields.
*
* Handles:
* - Simple values: `name: my-skill`
* - Quoted strings: `description: 'Some text'` or `description: "Some text"`
* - Multi-line folded: `description: >-\n line1\n line2`
*/
-export function parseSkillFrontmatter(
- content: string,
-): { name: string; description: string } | null {
+export function parseSkillFrontmatter(content: string): {
+ name: string;
+ description: string;
+ disableModelInvocation: boolean;
+} | null {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;
@@ -18,7 +20,15 @@ export function parseSkillFrontmatter(
if (!name) return null;
const description = extractYamlValue(yaml, "description") ?? "";
- return { name, description };
+ const disableModelInvocation = parseYamlBoolean(
+ extractYamlValue(yaml, "disable-model-invocation"),
+ );
+ return { name, description, disableModelInvocation };
+}
+
+/** YAML 1.2 core-schema booleans: `true`/`True`/`TRUE` (quoted forms too). */
+function parseYamlBoolean(value: string | null): boolean {
+ return value !== null && value.toLowerCase() === "true";
}
/**
@@ -100,7 +110,8 @@ function extractYamlValue(yaml: string, key: string): string | null {
return collectIndentedLines(lines, i + 1).join("\n");
}
- // Quoted string (single or double)
+ // Quoted string (single or double). Quoted content is literal in YAML —
+ // a `#` inside never starts a comment, so no comment stripping here.
if (
(rawValue.startsWith("'") && rawValue.endsWith("'")) ||
(rawValue.startsWith('"') && rawValue.endsWith('"'))
@@ -108,8 +119,10 @@ function extractYamlValue(yaml: string, key: string): string | null {
return rawValue.slice(1, -1);
}
- // Plain scalar
- return rawValue;
+ // Plain scalar: a `#` preceded by whitespace starts a trailing comment.
+ // Stripping it may uncover a quoted scalar (`"true" # manual only`).
+ const withoutComment = rawValue.replace(/\s+#.*$/, "").trim();
+ return unquoteYamlScalar(withoutComment);
}
return null;
diff --git a/packages/workspace-server/src/services/skills/schemas.ts b/packages/workspace-server/src/services/skills/schemas.ts
index 7c08afadd5..9a4e0cbd83 100644
--- a/packages/workspace-server/src/services/skills/schemas.ts
+++ b/packages/workspace-server/src/services/skills/schemas.ts
@@ -17,6 +17,9 @@ export const skillInfo = z.object({
repoName: z.string().optional(),
editable: z.boolean(),
skillMdBytes: z.number(),
+ // Frontmatter `disable-model-invocation: true`: only explicit user
+ // invocation runs the skill; the agent never picks it on its own.
+ disableModelInvocation: z.boolean().optional(),
});
export const listSkillsOutput = z.array(skillInfo);
@@ -59,6 +62,7 @@ export const saveSkillManifestInput = z.object({
name: z.string(),
description: z.string(),
body: z.string(),
+ disableModelInvocation: z.boolean().optional(),
});
export const saveSkillFileInput = z.object({
diff --git a/packages/workspace-server/src/services/skills/skill-discovery.ts b/packages/workspace-server/src/services/skills/skill-discovery.ts
index 7bb0f7c679..72ea3fcd95 100644
--- a/packages/workspace-server/src/services/skills/skill-discovery.ts
+++ b/packages/workspace-server/src/services/skills/skill-discovery.ts
@@ -176,7 +176,7 @@ export async function readSkillMetadataFromDir(
if (skillNames.length === 0) return [];
const results = await Promise.all(
- skillNames.map(async (skillName) => {
+ skillNames.map(async (skillName): Promise => {
const skillPath = path.join(skillsDir, skillName);
try {
const content = await fs.promises.readFile(
@@ -192,6 +192,9 @@ export async function readSkillMetadataFromDir(
...(repoName ? { repoName } : {}),
editable: isEditableSource(source),
skillMdBytes: Buffer.byteLength(content, "utf-8"),
+ ...(frontmatter?.disableModelInvocation
+ ? { disableModelInvocation: true }
+ : {}),
} satisfies SkillInfo;
} catch {
return null;
diff --git a/packages/workspace-server/src/services/skills/skills.test.ts b/packages/workspace-server/src/services/skills/skills.test.ts
index dc06bf1b91..de26712bcb 100644
--- a/packages/workspace-server/src/services/skills/skills.test.ts
+++ b/packages/workspace-server/src/services/skills/skills.test.ts
@@ -75,6 +75,24 @@ describe("listSkills", () => {
expect(repoSkill?.editable).toBe(true);
expect(bundledSkill?.editable).toBe(false);
});
+
+ it("surfaces disable-model-invocation from frontmatter", async () => {
+ await createSkill(
+ repoSkillsDir,
+ "manual-skill",
+ `---\nname: manual-skill\ndescription: d\ndisable-model-invocation: true\n---\nbody`,
+ );
+ await createSkill(repoSkillsDir, "auto-skill");
+
+ const skills = await makeService().listSkills();
+
+ expect(
+ skills.find((s) => s.name === "manual-skill")?.disableModelInvocation,
+ ).toBe(true);
+ expect(
+ skills.find((s) => s.name === "auto-skill")?.disableModelInvocation,
+ ).toBeUndefined();
+ });
});
describe("getSkillContents", () => {
@@ -555,6 +573,35 @@ describe("skill mutations", () => {
expect(content).toContain("# Alpha");
});
+ it("writes and clears disable-model-invocation through manifest saves", async () => {
+ const skillPath = await createSkill(repoSkillsDir, "alpha");
+ const service = makeService();
+
+ await service.saveSkillManifest(skillPath, {
+ name: "alpha",
+ description: "d",
+ body: "body",
+ disableModelInvocation: true,
+ });
+ let skills = await service.listSkills();
+ expect(
+ skills.find((s) => s.path === skillPath)?.disableModelInvocation,
+ ).toBe(true);
+
+ await service.saveSkillManifest(skillPath, {
+ name: "alpha",
+ description: "d",
+ body: "body",
+ disableModelInvocation: false,
+ });
+ skills = await service.listSkills();
+ expect(
+ skills.find((s) => s.path === skillPath)?.disableModelInvocation,
+ ).toBeUndefined();
+ const content = await service.readSkillFile(skillPath, "SKILL.md");
+ expect(content).not.toContain("disable-model-invocation");
+ });
+
it("rejects manifest saves without a name", async () => {
const skillPath = await createSkill(repoSkillsDir, "alpha");
diff --git a/packages/workspace-server/src/services/skills/skills.ts b/packages/workspace-server/src/services/skills/skills.ts
index d5de57be0c..86472181f6 100644
--- a/packages/workspace-server/src/services/skills/skills.ts
+++ b/packages/workspace-server/src/services/skills/skills.ts
@@ -133,11 +133,20 @@ export class SkillsService {
async saveSkillManifest(
skillPath: string,
- manifest: { name: string; description: string; body: string },
+ manifest: {
+ name: string;
+ description: string;
+ body: string;
+ disableModelInvocation?: boolean;
+ },
): Promise {
const skillDir = await this.resolveWritableSkillDir(skillPath);
const content = serializeSkillMarkdown(
- { name: manifest.name.trim(), description: manifest.description.trim() },
+ {
+ name: manifest.name.trim(),
+ description: manifest.description.trim(),
+ disableModelInvocation: manifest.disableModelInvocation ?? false,
+ },
manifest.body,
);
// The writer and parser must agree, or the skill vanishes from the list.
diff --git a/packages/workspace-server/src/services/skills/write-skill-frontmatter.test.ts b/packages/workspace-server/src/services/skills/write-skill-frontmatter.test.ts
index ed4b6eae46..7f1967edd0 100644
--- a/packages/workspace-server/src/services/skills/write-skill-frontmatter.test.ts
+++ b/packages/workspace-server/src/services/skills/write-skill-frontmatter.test.ts
@@ -28,10 +28,40 @@ describe("serializeSkillMarkdown", () => {
const parsed = parseSkillFrontmatter(content);
- expect(parsed).toEqual({ name, description });
+ expect(parsed).toEqual({
+ name,
+ description,
+ disableModelInvocation: false,
+ });
},
);
+ it("emits disable-model-invocation and round-trips it", () => {
+ const content = serializeSkillMarkdown(
+ { name: "my-skill", description: "d", disableModelInvocation: true },
+ "The body",
+ );
+
+ expect(content).toBe(
+ "---\nname: my-skill\ndescription: d\ndisable-model-invocation: true\n---\n\nThe body\n",
+ );
+ expect(parseSkillFrontmatter(content)).toEqual({
+ name: "my-skill",
+ description: "d",
+ disableModelInvocation: true,
+ });
+ });
+
+ it("omits disable-model-invocation when false", () => {
+ const content = serializeSkillMarkdown(
+ { name: "my-skill", description: "d", disableModelInvocation: false },
+ "The body",
+ );
+
+ expect(content).not.toContain("disable-model-invocation");
+ expect(parseSkillFrontmatter(content)?.disableModelInvocation).toBe(false);
+ });
+
it("appends the body after the frontmatter with a trailing newline", () => {
const content = serializeSkillMarkdown(
{ name: "my-skill", description: "d" },