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
@@ -18,13 +18,13 @@ type Props<T> = {
/**
* Under `/api/admin/`.
*/
path: "mcps/set-mcp-access" | "tools/set-tool-access";
path: "mcps/set-mcp-access" | "tools/set-tool-access" | "skills/set-skill-access";
onSaved: (overview: T, msg: string) => void;
onCancel: () => void;
};
/**
* Who gets an MCP server or tool: the bots and workspaces picked here.
* Who gets an MCP server, tool or skill: the bots and workspaces picked here.
* "Every bot" (super admins only) gives it to every bot now and every new
* one; each can still drop it. A workspace gives it to every bot in
* it, there.
@@ -11,22 +11,35 @@ type Props = {
};
/**
* One MCP server or tool a bot or workspace has, and whether it's working.
* The settings page each kind is managed on.
*/
const ExtensionHrefs = {
mcp: "/admin/settings/mcps",
tool: "/admin/settings/tools",
skill: "/admin/settings/skills",
} as const;
/**
* One MCP server, tool or skill a bot or workspace has, and whether it's
* working. A skill has nothing to connect or run, so it never reports a
* failure.
*/
export default function ExtensionRow({ item, busy, onRemove }: Props) {
const state = !item.enabled
? "Off for every bot"
: item.state == "error"
? "Not working"
: item.state == "untested"
? "Not checked yet"
: "";
: item.kind == "skill"
? ""
: item.state == "error"
? "Not working"
: item.state == "untested"
? "Not checked yet"
: "";
return (
<SettingsListRow
avatar={<IconTile Icon={extensionIcon({ kind: item.kind })} />}
title={item.name}
href={item.kind == "mcp" ? "/admin/settings/mcps" : "/admin/settings/tools"}
href={ExtensionHrefs[item.kind]}
subtitle={[item.detail, state].filter(Boolean).join(" · ")}
>
{onRemove ? (
@@ -1,6 +1,6 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import { Plug, Wrench } from "lucide-react";
import { BookOpen, Plug, Wrench } from "lucide-react";
import ExtensionAddSelect from "@/src/components/general/extension-add-select";
import useExtensions from "@/src/hooks/use-extensions";
import type { BotExtensionsPayload } from "@/src/types";
@@ -17,16 +17,16 @@ type Props = {
};
/**
* The MCP servers and tools a bot or a workspace has, removable, and a
* picker to add more. On the bot and workspace settings pages.
* The MCP servers, tools and skills a bot or a workspace has, removable,
* and a picker to add more. On the bot and workspace settings pages.
*/
export default function ExtensionsGroup({ owner, owner_name, description, initial }: Props) {
const extensions = useExtensions({ owner, owner_name, initial });
const id = "bot_id" in owner ? `bot-${owner.bot_id}` : `workspace-${owner.workspace_id}`;
return (
<SettingsGroup id="tools" title="Tools and MCP servers" description={description}>
<SettingsList is_empty={!extensions.attached.length} empty="No tools or MCP servers yet.">
<SettingsGroup id="tools" title="Tools, MCP servers and skills" description={description}>
<SettingsList is_empty={!extensions.attached.length} empty="No tools, MCP servers or skills yet.">
{extensions.attached.map((item) => (
<ExtensionRow
key={`${item.kind}:${item.id}`}
@@ -73,6 +73,18 @@ export default function ExtensionsGroup({ owner, owner_name, description, initia
>
Tools
</Button>
<Button
title="Manage skills"
type="button"
variant="ghost"
color="gray"
size="small"
href="/admin/settings/skills"
beforeIcon={<BookOpen size={14} aria-hidden />}
className="gap-1!"
>
Skills
</Button>
</Row>
<SettingsStatus status={extensions.status} />
</SettingsGroup>
@@ -75,7 +75,7 @@ export default function BotSettingsSection() {
<ExtensionsGroup
owner={{ bot_id: bot.id }}
owner_name={name}
description="What it can use besides its own tools, everywhere it works. A workspace's own come on top while it works there."
description="What it can use besides its own tools, everywhere it works: MCP servers, tools and skills. A workspace's own come on top while it works there."
initial={pageProps?.bot_extensions}
/>
<ScopedEnvsGroup initial={pageProps?.bot_envs} />
@@ -0,0 +1,120 @@
import { useState } from "react";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea";
import CodeEditor from "@/src/components/general/code-editor";
import { AppData } from "@/src/data/app-data";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { PublicSkill, SkillsOverviewPayload } from "@/src/types";
type Props = {
/**
* Edits this skill; adds one when not given.
*/
skill?: PublicSkill;
onSaved: (overview: SkillsOverviewPayload, msg: string) => void;
onCancel: () => void;
};
/**
* Adds or edits a skill: the name the settings page shows, the slug a bot
* loads it by, the one line that tells the bot when it applies, and the
* runbook itself in markdown.
*/
export default function SkillModal({ skill, onSaved, onCancel }: Props) {
const [name, setName] = useState(skill?.name || "");
const [slug, setSlug] = useState(skill?.slug || "");
const [description, setDescription] = useState(skill?.description || "");
const [body, setBody] = useState(skill?.body || "");
const [enabled, setEnabled] = useState(skill?.enabled ?? true);
const [all_bots, setAllBots] = useState(skill?.access.all_bots ?? false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const id = `skill-${skill?.id || "new"}`;
const submit = async () => {
if (loading || !window.confirm(skill ? `Save ${skill.name}?` : `Add ${name || "this skill"}?`)) return;
setLoading(true);
setError(undefined);
const res = await postAdminApi<SkillsOverviewPayload>({
path: "skills/save-skill",
body: { skill_id: skill?.id, name, slug, description, body, enabled, all_bots },
});
setLoading(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Couldn't save the skill.");
return;
}
onSaved(res.singleRes, res.msg || "Saved.");
};
return (
<AddModalShell
open
setOpen={(next) => !next && onCancel()}
title={skill ? `Edit ${skill.name}` : "Add a skill"}
description={"Bots get it once it's attached to them."}
>
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Input
id={`${id}-name`}
title="Name"
label="Name"
showLabel
placeholder="Weekly SEO report"
defaultValue={name}
autoComplete="off"
info="What the settings page calls it."
onChange={(e) => setName(e.target.value)}
/>
<Input
id={`${id}-slug`}
title="Slug"
label="Slug"
showLabel
placeholder="weekly-seo-report"
defaultValue={slug}
autoComplete="off"
info="What a bot passes to load_skill: lowercase letters, numbers and hyphens. Unique."
onChange={(e) => setSlug(e.target.value)}
/>
<Textarea
id={`${id}-description`}
title="When to use it"
label="When to use it"
showLabel
rows={2}
placeholder="Use for the Monday search report: rankings, traffic and what changed."
defaultValue={description}
info="The one line a bot reads to decide whether this skill applies. Say what job it covers."
onChange={(e) => setDescription(e.target.value)}
/>
<CodeEditor
mode="markdown"
className="h-80"
title="Runbook"
content={body}
info={`Markdown. The steps, the checks, and what good looks like. A bot reads it only when it loads the skill, up to ${AppData.SkillBodyShownChars} characters of it at a time.`}
onChange={(value) => setBody(value)}
/>
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
<Checkbox
title="Every bot"
label="Every bot by default: every bot now and every new one, the coordinator included. Each can still drop it."
checked={all_bots}
changeHandler={setAllBots}
/>
<AddFormActions submit_label={skill ? "Save" : "Add skill"} loading={loading} error={error} onCancel={onCancel} />
</Form>
</AddModalShell>
);
}
@@ -0,0 +1,91 @@
import { useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import { BookOpen } from "lucide-react";
import IconTile from "@/src/components/general/icon-tile";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { ExtensionTargets, PublicSkill, SkillsOverviewPayload } from "@/src/types";
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
import extensionAccessLabel from "../../(utils)/extension-access-label";
import SkillModal from "./skill-modal";
type Props = {
skill: PublicSkill;
targets: ExtensionTargets;
onChanged: (overview: SkillsOverviewPayload, msg: string) => void;
onError: (msg: string) => void;
};
/**
* One skill: its slug and when it applies, and who gets it. Edit and Access
* open in a modal.
*/
export default function SkillRow({ skill, targets, onChanged, onError }: Props) {
const [open, setOpen] = useState<"edit" | "access">();
const [busy, setBusy] = useState(false);
const saved = (overview: SkillsOverviewPayload, msg: string) => {
setOpen(undefined);
onChanged(overview, msg);
};
const remove = async () => {
if (busy || !window.confirm(`Delete ${skill.name}? Bots lose it at once.`)) return;
setBusy(true);
const res = await postAdminApi<SkillsOverviewPayload>({
path: "skills/delete-skill",
body: { skill_id: skill.id },
});
setBusy(false);
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Deleted.");
else onError(res.msg || "Couldn't delete the skill.");
};
return (
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
<SettingsListRow
avatar={<IconTile Icon={BookOpen} />}
title={skill.name}
badge={skill.enabled ? undefined : "Off"}
subtitle={[skill.slug, extensionAccessLabel({ access: skill.access }), skill.description]
.filter(Boolean)
.join(" · ")}
>
{targets.can_edit ? (
<SettingsRowAction
title={`Edit ${skill.name}`}
label="Edit"
disabled={busy}
onClick={() => setOpen(open == "edit" ? undefined : "edit")}
/>
) : null}
<SettingsRowAction
title={`Choose who gets ${skill.name}`}
label="Access"
disabled={busy}
onClick={() => setOpen(open == "access" ? undefined : "access")}
/>
{targets.can_edit ? (
<SettingsRowAction title={`Delete ${skill.name}`} label="Delete" disabled={busy} onClick={remove} />
) : null}
</SettingsListRow>
{open == "edit" ? (
<SkillModal skill={skill} onSaved={saved} onCancel={() => setOpen(undefined)} />
) : null}
{open == "access" ? (
<ExtensionAccessModal<SkillsOverviewPayload>
id={skill.id}
name={skill.name}
access={skill.access}
targets={targets}
path="skills/set-skill-access"
onSaved={saved}
onCancel={() => setOpen(undefined)}
/>
) : null}
</Stack>
);
}
@@ -0,0 +1,79 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { SkillsOverviewPayload } from "@/src/types";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import SkillModal from "../(partials)/skill-modal";
import SkillRow from "../(partials)/skill-row";
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* Every skill with who gets it. Super admins add, edit and delete them;
* admins choose which bots and workspaces get each one.
*/
export default function SkillsSettingsSection() {
const { pageProps } = useContext(AppContext);
const [overview, setOverview] = useState(pageProps?.skills_overview);
const [adding, setAdding] = useState(false);
const [status, setStatus] = useState<Status>();
/**
* Remounts the rows when the data changes under them.
*/
const [version, setVersion] = useState(0);
if (!overview) return null;
const changed = (next: SkillsOverviewPayload, msg: string) => {
setOverview(next);
setStatus({ success: true, msg });
setVersion((prev) => prev + 1);
};
const failed = (msg: string) => setStatus({ error: true, msg });
return (
<SettingsGroup
title="Skills"
description="A runbook for a job a bot does: the steps, the checks and what good looks like. A bot sees each skill's name and when it applies; it reads the whole thing only when it loads it, so a long one costs nothing until it's needed. Give one to a bot on its settings page or in its chat, or to a workspace to reach every bot there."
>
{adding ? (
<SkillModal
onSaved={(next, msg) => {
setAdding(false);
changed(next, msg);
}}
onCancel={() => setAdding(false)}
/>
) : null}
<SettingsList is_empty={!overview.skills.length} empty="No skills yet.">
{overview.skills.map((skill) => (
<SkillRow
key={`${skill.id}-${version}`}
skill={skill}
targets={overview}
onChanged={changed}
onError={failed}
/>
))}
</SettingsList>
{overview.can_edit && !adding ? (
<Button
title="Add a skill"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setAdding(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New skill
</Button>
) : null}
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,20 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabSkillsOverview from "@/src/functions/backend/db/extensions/grab-skills-overview";
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
/**
* Super admins and admins: admins only choose who gets each skill.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabCrewSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
return {
props: {
...settings.props,
skills_overview: await grabSkillsOverview({ user_id: settings.props.person.id }),
},
};
};
+13
View File
@@ -0,0 +1,13 @@
import SkillsSettingsSection from "./(sections)/skills-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsSkillsPage() {
return (
<SettingsShell title="Skills" description="Runbooks your bots follow when a job matches one.">
<SkillsSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Skills" });
@@ -68,7 +68,7 @@ export default function WorkspaceOverview() {
<ExtensionsGroup
owner={{ workspace_id }}
owner_name={name}
description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here."
description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here: MCP servers, tools and skills."
initial={pageProps?.workspace_extensions}
/>
<ScopedEnvsGroup initial={pageProps?.workspace_envs} />