Files
cobble/src/hooks/use-extensions.ts
T
tben 08d02e6033 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.
2026-10-22 12:31:42 +01:00

78 lines
2.5 KiB
TypeScript

import { useState } from "react";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { BotExtension, BotExtensionsPayload } from "@/src/types";
type Params = {
/**
* Whose they are: a bot's (`workspace_id` is where it's seen, so that
* workspace's show too) or a workspace's.
*/
owner: { bot_id: number; workspace_id?: number | null } | { workspace_id: number };
/**
* The bot's or workspace's name, for the confirmations.
*/
owner_name: string;
initial?: BotExtensionsPayload | null;
};
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* A bot's or workspace's MCP servers, tools and skills, with adding and removing
* them (each asks first). Shared by the bot and workspace settings pages
* and the chat's composer.
*/
export default function useExtensions({ owner, owner_name, initial }: Params) {
const [data, setData] = useState<BotExtensionsPayload>(
initial || { attached: [], addable: [], can_manage: false },
);
const [busy, setBusy] = useState<string>();
const [status, setStatus] = useState<Status>();
const api = "bot_id" in owner ? "bots" : "workspaces";
const change = async ({
action,
kind,
id,
}: {
action: "add" | "remove";
kind: BotExtension["kind"];
id: number;
}) => {
setBusy(`${kind}:${id}`);
setStatus(undefined);
const res = await postAdminApi<BotExtensionsPayload>({
path: `${api}/${action}-extension`,
body: { ...owner, kind, id },
});
setBusy(undefined);
if (!res.success || !res.singleRes) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return false;
}
setData(res.singleRes);
setStatus({ success: true, msg: res.msg || "Saved." });
return true;
};
const add = async ({ kind, id }: { kind: BotExtension["kind"]; id: number }) => {
const item = data.addable.find((entry) => entry.kind == kind && entry.id == id);
if (!window.confirm(`Give ${owner_name} ${item?.name || "this"}?`)) return false;
return change({ action: "add", kind, id });
};
const remove = async ({ kind, id, name }: { kind: BotExtension["kind"]; id: number; name: string }) => {
if (!window.confirm(`Remove ${name} from ${owner_name}?`)) return false;
return change({ action: "remove", kind, id });
};
return { ...data, busy, status, add, remove };
}