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
@@ -8,7 +8,7 @@ type Params = {
};
/**
* Throws unless the user manages this bot's MCP servers and tools: super
* Throws unless the user manages this bot's MCP servers, tools and skills: super
* admins and admins, the super admin alone for the coordinator. Returns
* the bot.
*/
@@ -5,8 +5,8 @@ type Params = {
};
/**
* Gives a new bot every MCP server and tool set to "every bot". Called
* when a bot is created; it can drop any of them afterwards.
* Gives a new bot every MCP server, tool and skill set to "every bot".
* Called when a bot is created; it can drop any of them afterwards.
*/
export default async function attachDefaultExtensions({ bot_id }: Params) {
const now = Date.now();
@@ -22,4 +22,10 @@ export default async function attachDefaultExtensions({ bot_id }: Params) {
SELECT ?, ?, ?, id FROM custom_tools WHERE all_bots = 1`,
values: [now, now, Number(bot_id)],
});
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_skills (created_at, updated_at, bot_id, skill_id)
SELECT ?, ?, ?, id FROM skills WHERE all_bots = 1`,
values: [now, now, Number(bot_id)],
});
}
@@ -0,0 +1,25 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import grabSkill from "./grab-skill";
type Params = {
skill_id: number | string;
};
/**
* Deletes a skill, and its bot and workspace links with it. The links are
* deleted here rather than left to the schema's cascade: foreign keys are
* off outside the server process (`enableForeignKeys`), so a delete from a
* script would otherwise strand them.
*/
export default async function deleteSkill({ skill_id }: Params) {
const skill = await grabSkill({ skill_id });
await BunSQLite.sql({ sql: `DELETE FROM bot_skills WHERE skill_id = ?`, values: [skill.id] });
await BunSQLite.sql({ sql: `DELETE FROM workspace_skills WHERE skill_id = ?`, values: [skill.id] });
const res = await BunSQLite.sql({ sql: `DELETE FROM skills WHERE id = ?`, values: [skill.id] });
if (!res.success) throw new Error(`Couldn't delete the skill: ${res.error}`);
return skill;
}
@@ -17,6 +17,13 @@ export const ExtensionTables = {
column: "custom_tool_id",
label: "tool",
},
skill: {
table: "skills",
bot_link: "bot_skills",
workspace_link: "workspace_skills",
column: "skill_id",
label: "skill",
},
} as const;
export type ExtensionKind = keyof typeof ExtensionTables;
@@ -11,14 +11,14 @@ type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The workspace the bot is seen in: its MCP servers and tools show
* The workspace the bot is seen in: its MCP servers, tools and skills show
* too, not removable from the bot.
*/
workspace_id?: number | string | null;
};
/**
* A bot's MCP servers and tools (given to it, and its workspace's there),
* A bot's MCP servers, tools and skills (given to it, and its workspace's there),
* and the rest of them, which can be added.
*/
export default async function grabBotExtensionsOverview({
@@ -1,8 +1,13 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db";
import type {
BUN_SQLITE_COBBLE_CUSTOM_TOOLS,
BUN_SQLITE_COBBLE_MCP_SERVERS,
BUN_SQLITE_COBBLE_SKILLS,
} from "@/db/types/db";
import botExtensionsSql from "@/src/utils/bot-extensions-sql";
import parseCustomToolRow from "./parse-custom-tool-row";
import parseMcpServerRow from "./parse-mcp-server-row";
import parseSkillRow from "./parse-skill-row";
type Params = {
bot_id: number | string;
@@ -10,17 +15,19 @@ type Params = {
};
/**
* The MCP servers and custom tools a bot gets on one run
* The MCP servers, custom tools and skills a bot gets on one run
* (`botExtensionsSql`): every bot's, its own, and its workspace's there.
*/
export default async function grabBotExtensions({ bot_id, workspace_id }: Params) {
const [servers, tools] = await Promise.all([
const [servers, tools, skills] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_MCP_SERVERS>(botExtensionsSql({ kind: "mcp", bot_id, workspace_id })),
BunSQLite.sql<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>(botExtensionsSql({ kind: "tool", bot_id, workspace_id })),
BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>(botExtensionsSql({ kind: "skill", bot_id, workspace_id })),
]);
return {
servers: (servers.payload || []).map((row) => parseMcpServerRow({ row })),
tools: (tools.payload || []).map((row) => parseCustomToolRow({ row })),
skills: (skills.payload || []).map((row) => parseSkillRow({ row })),
};
}
@@ -1,10 +1,15 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db";
import type {
BUN_SQLITE_COBBLE_CUSTOM_TOOLS,
BUN_SQLITE_COBBLE_MCP_SERVERS,
BUN_SQLITE_COBBLE_SKILLS,
} from "@/db/types/db";
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
import { McpTransports } from "@/src/dict/mcp-dict";
import type { BotExtension, BotExtensionsPayload } from "@/src/types";
import parseCustomToolRow from "./parse-custom-tool-row";
import parseMcpServerRow from "./parse-mcp-server-row";
import parseSkillRow from "./parse-skill-row";
type Params = {
/**
@@ -23,7 +28,7 @@ type Params = {
type Linked<T> = T & { source: "bot" | "workspace" };
/**
* The MCP servers and tools a bot or a workspace has, and the rest of
* The MCP servers, tools and skills a bot or a workspace has, and the rest of
* them, which can be added (`grabBotExtensionsOverview`,
* `grabWorkspaceExtensionsOverview`).
*/
@@ -45,8 +50,9 @@ export default async function grabExtensionsOverview({
const mcp_query = linked("mcp_server_id", "bot_mcp_servers", "workspace_mcp_servers");
const tool_query = linked("custom_tool_id", "bot_custom_tools", "workspace_custom_tools");
const skill_query = linked("skill_id", "bot_skills", "workspace_skills");
const [servers, tools, all_servers, all_tools] = await Promise.all([
const [servers, tools, skills, all_servers, all_tools, all_skills] = await Promise.all([
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_MCP_SERVERS>>({
sql: mcp_query.sql.replace("%TABLE%", "mcp_servers"),
values: mcp_query.values,
@@ -55,12 +61,21 @@ export default async function grabExtensionsOverview({
sql: tool_query.sql.replace("%TABLE%", "custom_tools"),
values: tool_query.values,
}),
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_SKILLS>>({
sql: skill_query.sql.replace("%TABLE%", "skills"),
values: skill_query.values,
}),
can_manage
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM mcp_servers ORDER BY name COLLATE NOCASE` })
: null,
can_manage
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM custom_tools ORDER BY name` })
: null,
can_manage
? BunSQLite.sql<{ id: number; name: string }>({
sql: `SELECT id, name FROM skills ORDER BY name COLLATE NOCASE`,
})
: null,
]);
const attached: BotExtension[] = [
@@ -94,13 +109,27 @@ export default async function grabExtensionsOverview({
source: row.source,
};
}),
...(skills.payload || []).map((row) => {
const skill = parseSkillRow({ row });
return {
kind: "skill" as const,
id: skill.id,
name: skill.name,
description: skill.description,
detail: `Skill · ${skill.slug}`,
state: "ok" as const,
enabled: skill.enabled,
source: row.source,
};
}),
];
/**
* A bot can be given one its workspace has; a workspace only what it
* hasn't.
*/
const has = (kind: "mcp" | "tool", id: number) =>
const has = (kind: BotExtension["kind"], id: number) =>
attached.some((item) => item.kind == kind && item.id == id && (!bot || item.source == "bot"));
return {
@@ -112,6 +141,9 @@ export default async function grabExtensionsOverview({
...(all_tools?.payload || [])
.filter((row) => !has("tool", Number(row.id)))
.map((row) => ({ kind: "tool" as const, id: Number(row.id), name: row.name })),
...(all_skills?.payload || [])
.filter((row) => !has("skill", Number(row.id)))
.map((row) => ({ kind: "skill" as const, id: Number(row.id), name: row.name })),
],
can_manage,
};
@@ -0,0 +1,24 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import parseSkillRow from "./parse-skill-row";
type Params = {
skill_id: number | string;
};
/**
* One skill, or an `AppError` when there's none.
*/
export default async function grabSkill({ skill_id }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({
sql: `SELECT * FROM skills WHERE id = ?`,
values: [skill_id],
})
).payload?.[0];
if (!row) throw new AppError(`That skill doesn't exist.`);
return parseSkillRow({ row });
}
@@ -0,0 +1,34 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
import type { SkillsOverviewPayload } from "@/src/types";
import grabCrewRole from "../access/grab-crew-role";
import grabExtensionAccess from "./grab-extension-access";
import grabExtensionTargets from "./grab-extension-targets";
import parseSkillRow from "./parse-skill-row";
type Params = {
user_id: number | string;
};
/**
* The Skills settings page: every skill with who gets it, and what it can
* be attached to. A skill holds nothing secret, so admins read it too;
* only a super admin adds, edits or deletes.
*/
export default async function grabSkillsOverview({ user_id }: Params): Promise<SkillsOverviewPayload> {
const role = await grabCrewRole({ user_id });
const [rows, access, targets] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({ sql: `SELECT * FROM skills ORDER BY name COLLATE NOCASE ASC, id ASC` }),
grabExtensionAccess({ kind: "skill" }),
grabExtensionTargets({ role }),
]);
return {
...targets,
skills: (rows.payload || []).map((row) => {
const { all_bots, ...skill } = parseSkillRow({ row });
return { ...skill, access: { all_bots, ...access(skill.id) } };
}),
};
}
@@ -11,7 +11,7 @@ type Params = {
};
/**
* The MCP servers and tools every bot working in a workspace gets there,
* The MCP servers, tools and skills every bot working in a workspace gets there,
* and the rest of them, which can be added.
*/
export default async function grabWorkspaceExtensionsOverview({
@@ -0,0 +1,21 @@
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
import type { SkillRecord } from "@/src/types";
type Params = {
row: BUN_SQLITE_COBBLE_SKILLS;
};
/**
* A `skills` row, with its flags read as booleans.
*/
export default function parseSkillRow({ row }: Params): SkillRecord {
return {
id: Number(row.id),
name: row.name || "",
slug: row.slug || "",
description: row.description || "",
body: row.body || "",
all_bots: row.all_bots == 1,
enabled: row.enabled != 0,
};
}
@@ -0,0 +1,66 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import attachToAllBots from "./attach-to-all-bots";
import type { BUN_SQLITE_COBBLE_SKILLS, BunSQLiteTables } from "@/db/types/db";
import type { SaveSkillRequest } from "@/src/schemas/extension-schema";
import AppError from "@/src/utils/app-error";
import grabSkill from "./grab-skill";
type Params = {
/**
* The super admin saving it (checked by the caller).
*/
user_id: number | string;
data: SaveSkillRequest;
};
/**
* Adds or edits a skill and returns its id. Slugs are unique: a bot loads a
* skill by its slug, so two skills can't answer to the same one.
*/
export default async function saveSkill({ user_id, data }: Params) {
const clash = (
await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM skills WHERE slug = ? AND id != ?`,
values: [data.slug, data.skill_id || 0],
})
).payload?.[0];
if (clash) throw new AppError(`Another skill already uses the slug ${data.slug}.`);
const fields = {
name: data.name,
slug: data.slug,
description: data.description,
body: data.body,
enabled: data.enabled ? 1 : 0,
all_bots: data.all_bots ? 1 : 0,
} as const;
if (data.skill_id) {
const skill = await grabSkill({ skill_id: data.skill_id });
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({
table: "skills",
targetId: skill.id,
data: fields,
});
if (!res.success) throw new Error(`Couldn't save the skill: ${res.msg}`);
if (data.all_bots && !skill.all_bots) await attachToAllBots({ kind: "skill", id: skill.id });
return skill.id;
}
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({
table: "skills",
data: [{ ...fields, user_id: Number(user_id) }],
});
const id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !id) throw new Error(`Couldn't add the skill: ${res.msg}`);
if (data.all_bots) await attachToAllBots({ kind: "skill", id: Number(id) });
return Number(id);
}