Give bots skills: runbooks they load when a job matches

A skill is a runbook: a name, a unique slug, a one-line description of when
it applies, and the markdown body. A run only ever sees the slug and
description (one load_skill tool carries them), and reads the whole body
only when it calls the tool, so a long runbook costs nothing until needed.

Skills are the third extension kind beside MCP servers and custom tools, on
the same botExtensionsSql path, so per-bot, per-workspace and all-bots
grants work the same way, and attachDefaultExtensions hands an every-bot
skill to new bots. load_skill is a reserved tool name, so a custom tool
cannot shadow it, and a built-in wins a clash.

Managed on Settings > Skills; ExtensionRow links each kind to its own page
and a skill never reports a failure, since nothing connects or runs.
This commit is contained in:
2026-10-22 12:31:42 +01:00
parent 379ef35e08
commit 08d02e6033
62 changed files with 1438 additions and 79 deletions
+16
View File
@@ -8,6 +8,9 @@ beforeAll(() => {
db.run(`CREATE TABLE custom_tools (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled INTEGER)`);
db.run(`CREATE TABLE bot_custom_tools (bot_id INTEGER, custom_tool_id INTEGER)`);
db.run(`CREATE TABLE workspace_custom_tools (workspace_id INTEGER, custom_tool_id INTEGER)`);
db.run(`CREATE TABLE skills (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled INTEGER)`);
db.run(`CREATE TABLE bot_skills (bot_id INTEGER, skill_id INTEGER)`);
db.run(`CREATE TABLE workspace_skills (workspace_id INTEGER, skill_id INTEGER)`);
/**
* 1: every bot (given to 5 and 6). 2: bot 5. 3: workspace 9. 4: off,
* given to 5. 5: nobody. 6: every bot, but 6 dropped it.
@@ -15,6 +18,12 @@ beforeAll(() => {
db.run(`INSERT INTO custom_tools VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1), (6, 1, 1)`);
db.run(`INSERT INTO bot_custom_tools VALUES (5, 1), (6, 1), (5, 2), (5, 4), (5, 6)`);
db.run(`INSERT INTO workspace_custom_tools VALUES (9, 3)`);
/**
* The same shape, so skills reach a bot the same way.
*/
db.run(`INSERT INTO skills VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1), (6, 1, 1)`);
db.run(`INSERT INTO bot_skills VALUES (5, 1), (6, 1), (5, 2), (5, 4), (5, 6)`);
db.run(`INSERT INTO workspace_skills VALUES (9, 3)`);
});
function ids(params: Parameters<typeof botExtensionsSql>[0]) {
@@ -37,4 +46,11 @@ describe("botExtensionsSql", () => {
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 9 })).toEqual([1, 2, 3, 6]);
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2, 6]);
});
test("skills reach a bot the same way", () => {
expect(ids({ kind: "skill", bot_id: 5 })).toEqual([1, 2, 6]);
expect(ids({ kind: "skill", bot_id: 6 })).toEqual([1]);
expect(ids({ kind: "skill", bot_id: 7 })).toEqual([]);
expect(ids({ kind: "skill", bot_id: 6, workspace_id: 9 })).toEqual([1, 3]);
});
});
+9 -3
View File
@@ -1,8 +1,8 @@
type Params = {
/**
* Which kind: MCP servers or custom tools.
* Which kind: MCP servers, custom tools or skills.
*/
kind: "mcp" | "tool";
kind: "mcp" | "tool" | "skill";
bot_id: number | string;
/**
* The workspace the run is in: what's attached to it counts too.
@@ -23,10 +23,16 @@ const Tables = {
workspace_link: "workspace_custom_tools",
column: "custom_tool_id",
},
skill: {
table: "skills",
bot_link: "bot_skills",
workspace_link: "workspace_skills",
column: "skill_id",
},
} as const;
/**
* The enabled MCP servers or custom tools a bot gets on one run: those
* The enabled MCP servers, custom tools or skills a bot gets on one run: those
* attached to the bot, and in a workspace those attached to the
* workspace. "Every bot" only decides what a bot is given (now and when
* it's created, `attachToAllBots`, `attachDefaultExtensions`); one it
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import checkSkillSlug from "./check-skill-slug";
describe("checkSkillSlug", () => {
test("accepts lowercase words joined by single hyphens", () => {
expect(checkSkillSlug({ slug: "seo" })).toBeNull();
expect(checkSkillSlug({ slug: "weekly-seo-report" })).toBeNull();
expect(checkSkillSlug({ slug: "report2024" })).toBeNull();
});
test("refuses capitals, spaces, underscores and dots", () => {
expect(checkSkillSlug({ slug: "Weekly" })).not.toBeNull();
expect(checkSkillSlug({ slug: "weekly seo" })).not.toBeNull();
expect(checkSkillSlug({ slug: "weekly_seo" })).not.toBeNull();
expect(checkSkillSlug({ slug: "weekly.seo" })).not.toBeNull();
});
test("refuses a hyphen at either end and doubled hyphens", () => {
expect(checkSkillSlug({ slug: "-weekly" })).not.toBeNull();
expect(checkSkillSlug({ slug: "weekly-" })).not.toBeNull();
expect(checkSkillSlug({ slug: "weekly--seo" })).not.toBeNull();
});
test("refuses slugs shorter than 2 or longer than 64 characters", () => {
expect(checkSkillSlug({ slug: "a" })).not.toBeNull();
expect(checkSkillSlug({ slug: "a".repeat(65) })).not.toBeNull();
expect(checkSkillSlug({ slug: "a".repeat(64) })).toBeNull();
});
});
+22
View File
@@ -0,0 +1,22 @@
import { SkillSlugPattern } from "@/src/dict/skill-dict";
type Params = {
slug: string;
};
/**
* Why a skill can't take this slug, or null when it can: lowercase letters,
* numbers and single hyphens, 2 to 64 characters, never starting or ending
* with one. It's what a bot writes in `load_skill`.
*/
export default function checkSkillSlug({ slug }: Params): string | null {
if (slug.length < 2 || slug.length > 64) {
return `Use 2 to 64 characters.`;
}
if (!SkillSlugPattern.test(slug)) {
return `Use lowercase letters, numbers and hyphens, e.g. weekly-seo-report.`;
}
return null;
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import extensionNoun, { extensionLogTitle } from "./extension-noun";
describe("extensionNoun", () => {
test("names each kind, with or without its article", () => {
expect(extensionNoun({ kind: "mcp" })).toBe("MCP server");
expect(extensionNoun({ kind: "tool" })).toBe("tool");
expect(extensionNoun({ kind: "skill" })).toBe("skill");
expect(extensionNoun({ kind: "skill", article: true })).toBe("the skill");
});
});
describe("extensionLogTitle", () => {
test("capitalizes the noun and adds what happened", () => {
expect(extensionLogTitle({ kind: "mcp", verb: "added" })).toBe("MCP server added");
expect(extensionLogTitle({ kind: "tool", verb: "removed" })).toBe("Tool removed");
expect(extensionLogTitle({ kind: "skill", verb: "added" })).toBe("Skill added");
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { BotExtension } from "@/src/types";
type Params = {
kind: BotExtension["kind"];
/**
* With the article, for a sentence: "the tool", "the MCP server".
*/
article?: boolean;
};
/**
* What an extension kind is called in copy: a tool, an MCP server or a
* skill.
*/
export default function extensionNoun({ kind, article }: Params) {
const noun = kind == "mcp" ? "MCP server" : kind == "skill" ? "skill" : "tool";
return article ? `the ${noun}` : noun;
}
type TitleParams = {
kind: BotExtension["kind"];
/**
* What happened, in one word: "added", "removed".
*/
verb: string;
};
/**
* A log entry's title: "MCP server added", "Skill removed".
*/
export function extensionLogTitle({ kind, verb }: TitleParams) {
const noun = extensionNoun({ kind });
return `${noun.charAt(0).toUpperCase()}${noun.slice(1)} ${verb}`;
}
+9
View File
@@ -7,11 +7,20 @@ describe("extensionOptionValue", () => {
kind: "mcp",
id: 3,
});
expect(parseExtensionOptionValue({ value: extensionOptionValue({ kind: "tool", id: 4 }) })).toEqual({
kind: "tool",
id: 4,
});
expect(parseExtensionOptionValue({ value: extensionOptionValue({ kind: "skill", id: 5 }) })).toEqual({
kind: "skill",
id: 5,
});
});
test("refuses anything else", () => {
expect(parseExtensionOptionValue({ value: "" })).toBeNull();
expect(parseExtensionOptionValue({ value: "bot:2" })).toBeNull();
expect(parseExtensionOptionValue({ value: "tool:x" })).toBeNull();
expect(parseExtensionOptionValue({ value: "skill:0" })).toBeNull();
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
import type { BotExtension } from "@/src/types";
/**
* An MCP server or tool as one select value (`mcp:3`), and back.
* An MCP server, tool or skill as one select value (`mcp:3`), and back.
*/
export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"]; id: number }) {
return `${kind}:${id}`;
@@ -10,7 +10,7 @@ export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"];
export function parseExtensionOptionValue({ value }: { value: string }) {
const [kind, id] = value.split(":");
if ((kind != "mcp" && kind != "tool") || !Number(id)) return null;
if ((kind != "mcp" && kind != "tool" && kind != "skill") || !Number(id)) return null;
return { kind: kind as BotExtension["kind"], id: Number(id) };
}