- {/* The avatar and the two lines beside it, as they always were —
- "Owliver" over the page, with the pair doubling as the agent
- switcher. The header element, its geometry and the window controls
- to the right are unchanged. */}
-
+ {/* The avatar and the two lines beside it — "Owliver" over the page,
+ and who is answering. It is a label, not a control: choosing an
+ agent belongs to Agent Registry → Configure, not to this header.
+ The header element, its geometry and the window controls to the
+ right are unchanged. */}
+
{/* Window controls.
@@ -747,6 +798,46 @@ export default function KrowAssistant({
)}
+ {/**
+ * A capability test running through this panel.
+ *
+ * One line, in the panel's own vocabulary, so the reader knows the next
+ * answer is the thing they asked for rather than the page talking to
+ * itself. Deliberately outside the scrolling body: it describes the turn,
+ * not a message in it, and nothing about it is persisted with the thread.
+ */}
+ {panelTest && (
+ {
+ /**
+ * Asks something.
+ *
+ * `scope` runs this one turn against a *different* page and a different set
+ * of reachable skills, and exists for exactly one caller: testing a
+ * capability from the agent editor. The reader is standing on the
+ * configuration screen, so the panel's own context is the workspace — asking
+ * "what happened today?" there would resolve against the workspace's skills
+ * and prove nothing about the Activity capability being configured.
+ *
+ * It is an override of *which page this question is about*, never of what may
+ * be read: `resolveIntent` and the provider apply the same page rules to the
+ * substituted context that they apply to a real one, so a test cannot reach a
+ * record the real page would not have offered. The turn lands in the ordinary
+ * thread, is persisted and archived like any other, and nothing about it is
+ * simulated — it is the live pipeline pointed at another surface.
+ */
+ const send = React.useCallback(async ({
+ question, capability = null, positionId = null, scope = null,
+ }) => {
const text = String(question).trim();
if (!text) return;
+ /* The page this turn is about, and what is reachable there. Defaults are
+ the panel's own, so every existing caller is unchanged. */
+ const turnContext = scope?.contextId || contextId;
+ const turnDisabled = scope?.disabledSkills || disabledSkills;
+ const turnAgent = scope?.agent || agent;
+ const turnCovers = scope ? true : agentCoversPage;
+
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
@@ -419,7 +445,7 @@ export function useConversation({
*/
let intent = null;
if (flowRef.current) {
- const skill = skillsForContext(contextId, disabledSkills, customSkills)
+ const skill = skillsForContext(turnContext, turnDisabled, customSkills)
.find((s) => s.id === flowRef.current.skillId);
if (!skill) setFlow(null);
@@ -438,9 +464,14 @@ export function useConversation({
intent = capability
? { kind: 'answer' }
: resolveIntent({
- question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
+ question: text,
+ contextId: turnContext,
+ disabledSkills: turnDisabled,
+ customSkills, roles, skillCategories,
courses, workforce, skillContext, positionId,
- agent, agentCoversPage, agentSuggestion, owliverContext,
+ agent: turnAgent,
+ agentCoversPage: turnCovers,
+ agentSuggestion, owliverContext,
});
}
@@ -476,7 +507,7 @@ export function useConversation({
followUp: [
...createdFollowUp(created),
...(ready
- ? suggestionsForPosition(contextId, disabledSkills, customSkills, created)
+ ? suggestionsForPosition(turnContext, turnDisabled, customSkills, created)
: []),
],
};
@@ -664,10 +695,10 @@ export function useConversation({
try {
let latest = [];
for await (const snapshot of provider.stream({
- contextId, capability, question: text, facts, signal: controller.signal,
+ contextId: turnContext, capability, question: text, facts, signal: controller.signal,
/* What the agent *is*, never what it may read. The page settled that
before this call, and `agentRequest` carries no records. */
- agent: agent ? agentRequest(agent, contextId) : null,
+ agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
owliverContext,
})) {
if (controller.signal.aborted) break;
@@ -703,10 +734,39 @@ export function useConversation({
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
- customSkills, roles, skillCategories, courses, skillContext]);
+ customSkills, roles, skillCategories, courses, skillContext,
+ agent, agentCoversPage, agentSuggestion, owliverContext]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);
+ /**
+ * States something in the thread without a question having been asked.
+ *
+ * The workspace's own voice: "Attendance Analysis is now available to this
+ * agent." It is a real turn — persisted and archived by the same `persist`
+ * every answer goes through, so it survives a reload and appears in History
+ * exactly where it happened, rather than being a banner that evaporates.
+ *
+ * Nothing is generated. The caller supplies the sentence and the chips, which
+ * is what keeps this from being a second answering path: no skill runs, no
+ * provider is called, and nothing here can claim a figure.
+ */
+ const announce = React.useCallback(({ text: body, followUp = null }) => {
+ const sentence = String(body || '').trim();
+ if (!sentence) return;
+
+ const message = {
+ role: 'assistant',
+ text: sentence,
+ blocks: doc(textBlock(sentence)).blocks,
+ ...(followUp?.length ? { followUp } : null),
+ };
+
+ const next = [...messagesRef.current, message];
+ messagesRef.current = next;
+ persist(next);
+ }, [persist]);
+
/**
* Back to an empty panel.
*
@@ -797,6 +857,7 @@ export function useConversation({
error,
busy: Boolean(pending),
send,
+ announce,
stop,
reset,
submitFeedback,
diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx
index c91aa2a..676a34f 100644
--- a/src/layouts/AdminLayout.jsx
+++ b/src/layouts/AdminLayout.jsx
@@ -262,8 +262,12 @@ export default function AdminLayout() {
navigate('/admin/profile#security')} className="cursor-pointer">
Security
-
navigate('/admin/workspace')} className="cursor-pointer">
- Workspace
+ {/* Straight to the registry itself. The Workspace overview at
+ `/admin/workspace` still exists and still reaches the same
+ list through its own Manage Agents button — it is simply
+ not a stop on the way there from this menu. */}
+ navigate('/admin/workspace/agents')} className="cursor-pointer">
+ Agent Registry
(
({
+ contextId,
+ pageKey: pageKeyForContext(contextId),
+}));
+
+/** The assistant context for a surface, or null if the page carries no panel. */
+export function contextForPage(pageKey) {
+ const wanted = canonicalPage(pageKey) || pageKey;
+ const hit = CONTEXTS.find((c) => c.pageKey && (canonicalPage(c.pageKey) || c.pageKey) === wanted);
+ return hit?.contextId || null;
+}
+
+/**
+ * The surfaces a capability can actually be tried on, for this agent.
+ *
+ * The intersection of what the agent covers and what the capability declares —
+ * anywhere else the runtime would decline, and offering it as a test target
+ * would be offering a test guaranteed to fail for a reason that is not about
+ * the capability.
+ */
+export function testTargets(agentPages = [], skillPages = []) {
+ const skill = new Set((skillPages || []).map((p) => canonicalPage(p) || p));
+ return (agentPages || [])
+ .map((p) => canonicalPage(p) || p)
+ .filter((p) => skill.has(p))
+ .map((pageKey) => ({
+ pageKey,
+ contextId: contextForPage(pageKey),
+ label: surfaceFor(pageKey)?.label || pageKey,
+ }))
+ .filter((t) => t.contextId);
+}
+
+/**
+ * The agent as it *would* be with this capability attached.
+ *
+ * Published on purpose: an unpublished draft is refused by the runtime for a
+ * reason that has nothing to do with the capability being tried, and a test
+ * that always says "this agent is not published" answers the wrong question.
+ */
+function hypotheticalAgent(fields, skillId) {
+ const skills = skillId && !fields.skills.includes(skillId)
+ ? [...fields.skills, skillId]
+ : fields.skills;
+
+ return {
+ id: fields.id || 'draft',
+ name: fields.name || 'This agent',
+ pages: fields.pages,
+ skills,
+ subagents: fields.subagents || [],
+ knowledge: fields.knowledge || [],
+ starters: fields.starters || [],
+ reasoning: fields.reasoning,
+ webSearch: fields.webSearch,
+ status: 'published',
+ };
+}
+
+/**
+ * What the runtime would do with this question, on this page, with this
+ * capability added.
+ *
+ * `matched` is the honest headline: it is the skill that would actually answer.
+ * When that is the capability under test, the test proves the capability;
+ * when it is a different one, it says so rather than claiming a pass — two
+ * definitions claiming the same phrase is a real condition and the reader
+ * should see it here rather than discover it in production.
+ */
+export function evaluateCapability({
+ fields, skillId, contextId, question, customSkills = [], entry = null,
+}) {
+ const agent = hypotheticalAgent(fields, skillId);
+ const registry = allSkills(customSkills);
+ const disabledSkills = agentScopedDisabled(agent, registry, []);
+
+ const covers = agentCovers(agent, contextId);
+ const reachable = covers ? skillsForContext(contextId, disabledSkills, customSkills) : [];
+ const offered = reachable.some((s) => s.id === skillId);
+
+ /**
+ * There are two ways a question reaches a skill, and conflating them made
+ * this panel lie about its own suggestions.
+ *
+ * A **typed** question is routed by `matchSkill` against declared triggers. A
+ * **suggestion** is not routed at all: it is a chip, it carries the capability
+ * it asks for, and `send` short-circuits intent resolution for exactly that
+ * reason (`capability ? { kind: 'answer' }`). So "What kinds of event are
+ * there?" — a suggestion Activity Analysis declares — matches none of its
+ * triggers and is still answered by it every time.
+ *
+ * Reporting that as "no skill claims this wording" was true of the matcher and
+ * false of the product. Both paths are modelled here, and the one that applies
+ * is named, so the reader is told *how* it would be answered rather than being
+ * shown a warning about a question that works.
+ */
+ const declared = (entry?.suggestions || []).find(
+ (sug) => (sug.prompt || sug.label) === question
+ );
+
+ const matched = covers && question && !declared
+ ? matchSkill(question, contextId, disabledSkills, customSkills)
+ : null;
+
+ return {
+ agent,
+ disabledSkills,
+ covers,
+ /** Is the capability under test offered at all on this page? */
+ reachable: offered,
+ reachableCount: reachable.length,
+ /** The skill that would answer a *typed* question, if any. */
+ matched,
+ /** True when the capability being tested is the one that answers. */
+ claims: Boolean(offered && (declared || (matched && matched.id === skillId))),
+ /** How it would be answered: its own suggestion, or a matched trigger. */
+ route: declared ? 'suggestion' : matched ? 'trigger' : null,
+ /** The capability a suggestion asks for — passed to `send` as a chip would. */
+ capability: declared?.capability || null,
+ tools: covers ? toolsForContext(contextId, disabledSkills, customSkills) : [],
+ pageLabel: ASSISTANT_CONTEXTS[contextId]?.page || contextId,
+ };
+}
+
+/**
+ * What `send({ scope })` needs to run this turn against the target page.
+ *
+ * Deliberately the same `disabledSkills` the evaluation used, so the answer the
+ * reader reads is produced under exactly the conditions the diagnostics above
+ * described.
+ */
+export const scopeFor = (evaluation, contextId) => ({
+ contextId,
+ disabledSkills: evaluation.disabledSkills,
+ agent: evaluation.agent,
+});
+
+/**
+ * Questions worth trying, taken from the capability itself.
+ *
+ * A definition's `owliver.suggestions` are the questions its author wrote it to
+ * answer, so they are the fairest test of it — and they keep the test from
+ * being a blank box the reader has to guess at. A definition with none falls
+ * back to its own name, which is what its default trigger matches.
+ */
+export function testQuestions(entry) {
+ const declared = (entry?.suggestions || []).map((s) => s.prompt || s.label).filter(Boolean);
+ if (declared.length) return declared.slice(0, 3);
+ return entry?.name ? [entry.name] : [];
+}
diff --git a/src/lib/skills/catalog.js b/src/lib/skills/catalog.js
new file mode 100644
index 0000000..39667d2
--- /dev/null
+++ b/src/lib/skills/catalog.js
@@ -0,0 +1,431 @@
+import { aiAgentSkills, allSkills, getSkillsForPage, skillsWithFacet } from './registry';
+import { canonicalPage, placementLabel, sectionTypeLabel, surfaceFor } from './surfaces';
+import { describeTool } from './tools';
+
+/**
+ * The skill catalog — the registry, read as something a person browses.
+ *
+ * There is **no second list of skills here**. Every entry is a definition
+ * `allSkills` already returned, and every field on it is read off that
+ * definition: what it can be asked for comes from its `owliver.capabilities`,
+ * what it can do comes from its `actions`, and where it applies comes from its
+ * `pages`. A catalog that carried its own copy of any of that would eventually
+ * offer an agent a skill the runtime does not have — which is the exact failure
+ * `AddSkillsModal` was written to avoid, and this keeps.
+ *
+ * What it adds is *grouping*. A definition may declare `category:` and eleven of
+ * them do; the rest are placed by what they demonstrably are, never by their id.
+ * That rule matters: the registry is explicit that nothing downstream may test a
+ * page or a skill by name, so a definition authored tomorrow has to land in a
+ * group without this file being edited. It does — every step below reads a
+ * declaration.
+ */
+
+/**
+ * The groups, in the order a reader meets them.
+ *
+ * Deliberately Krow's own domains rather than a generic "Core / Development /
+ * Data" taxonomy: this workspace hires and rosters people, and a category
+ * called Development would be a heading with nothing under it.
+ *
+ * A definition may still declare a `category:` outside this list — the registry
+ * keeps that field free text on purpose — and `catalogGroups` surfaces it beside
+ * these rather than dropping it.
+ */
+export const SKILL_GROUPS = [
+ {
+ id: 'analytics',
+ label: 'Analytics',
+ blurb: 'The workspace as figures — trends, coverage and the headline picture.',
+ },
+ {
+ id: 'hiring',
+ label: 'Hiring',
+ blurb: 'The pipeline: open roles, applicants, and who has already been hired.',
+ },
+ {
+ id: 'workforce',
+ label: 'Workforce',
+ blurb: 'The people already on the roster — attendance, hours and training.',
+ },
+ {
+ id: 'operations',
+ label: 'Operations',
+ blurb: 'What is happening now, and what is going wrong.',
+ },
+ {
+ id: 'authoring',
+ label: 'Authoring',
+ blurb: 'Skills that create a record from the conversation rather than reading one.',
+ },
+];
+
+const GROUP_BY_ID = new Map(SKILL_GROUPS.map((g) => [g.id, g]));
+
+/**
+ * The group a surface belongs to.
+ *
+ * Keyed on the closed surface vocabulary rather than on skill ids, so this is a
+ * statement about the product's pages — which are a fixed set — and not about
+ * any particular definition. A skill attaching to a surface listed here inherits
+ * its group for free.
+ */
+const GROUP_BY_SURFACE = {
+ 'control-center': 'analytics',
+ analytics: 'analytics',
+ positions: 'hiring',
+ 'create-position': 'hiring',
+ candidates: 'hiring',
+ 'candidates-analysis': 'hiring',
+ 'hired-history': 'hiring',
+ 'talent-pool': 'hiring',
+ activity: 'operations',
+ 'krow-forge': 'workforce',
+ profile: 'workforce',
+};
+
+/**
+ * Whether this definition *writes* rather than reads.
+ *
+ * Read off two declarations, both of which mean the same thing in different
+ * words: a `prompt:` is a definition offering to start a piece of work, and an
+ * action the tool table marks as needing approval is one that changes a record.
+ * Either makes a skill an authoring skill, and neither is a name.
+ */
+function isAuthoring(skill) {
+ if (skill.prompt) return true;
+ return (skill.actions || []).some((name) => {
+ const tool = describeTool(name);
+ return Boolean(tool && (tool.mutates || tool.requiresApproval));
+ });
+}
+
+/**
+ * Which group a definition belongs to.
+ *
+ * Its own `category:` always wins — an author who wrote one has already
+ * answered this question. Everything after that is inference, in the order of
+ * how much the definition is actually saying: what it does, then where it
+ * applies, then nothing.
+ */
+export function groupFor(skill) {
+ const declared = String(skill.category || '').trim().toLowerCase();
+ if (declared) return declared;
+
+ if (isAuthoring(skill)) return 'authoring';
+
+ for (const page of skill.pages || []) {
+ const group = GROUP_BY_SURFACE[page];
+ if (group) return group;
+ }
+
+ return 'general';
+}
+
+/** A group's label, whether it is one of ours or one an author invented. */
+export const groupLabel = (id) => GROUP_BY_ID.get(id)?.label
+ || String(id || '').replace(/[-_]/g, ' ').replace(/^./, (c) => c.toUpperCase());
+
+/**
+ * What a skill can be asked for, in the reader's words.
+ *
+ * The `owliver:` capabilities are the machine-readable half — `summary`,
+ * `table`, `flow` — and the `## Capabilities` bullets are the sentence the
+ * author wrote. Both are shown, because one says what shape an answer takes and
+ * the other says what the answer is about.
+ */
+function capabilitiesOf(skill) {
+ const declared = skill.owliver?.capabilities || [];
+ return {
+ /** Response shapes this skill offers in the panel. */
+ shapes: [...declared],
+ /** The author's own description of what it can do. */
+ described: skill.capabilities || [],
+ };
+}
+
+/** The tools a skill reaches, described — never a tool it did not declare. */
+function toolsOf(skill) {
+ return (skill.actions || [])
+ .map((name) => describeTool(name) || { name, label: name, summary: '', readOnly: true })
+ .filter(Boolean);
+}
+
+/** The surfaces a skill answers on, as the product names them. */
+function surfacesOf(skill) {
+ return (skill.pages || []).map((page) => ({
+ id: page,
+ label: surfaceFor(page)?.label || page,
+ }));
+}
+
+/**
+ * One catalog entry: a definition, plus the readings a card and a details panel
+ * need. Nothing is invented — every field traces back to the parsed skill.
+ */
+export function catalogEntry(skill) {
+ const group = groupFor(skill);
+ return {
+ id: skill.id,
+ name: skill.name,
+ description: skill.description,
+ group,
+ groupLabel: groupLabel(group),
+ /** Owliver, Board, or both — from the definition's own facets. */
+ type: capabilityType(skill),
+ capabilities: capabilitiesOf(skill),
+ tools: toolsOf(skill),
+ surfaces: surfacesOf(skill),
+ /** Questions this skill was written to be asked, offered as chips. */
+ suggestions: (skill.owliver?.suggestions || []).map((s) => ({
+ label: s.label,
+ prompt: s.prompt,
+ capability: s.capability ?? null,
+ })),
+ /** How many questions its guided flow asks, when it has one. */
+ questions: (skill.conversation || []).length,
+ prompt: skill.prompt || null,
+ custom: Boolean(skill.custom),
+ status: skill.status,
+ };
+}
+
+/**
+ * Every skill an agent may carry, as catalog entries.
+ *
+ * The same filter `AddSkillsModal` applied, kept exactly: assistant skills with
+ * the `owliver` facet and an active status. A workforce training path is
+ * something a *person* learns — offering one here would promise an agent
+ * behaviour that does not exist.
+ */
+export function skillCatalog(customSkills = [], { pages = null } = {}) {
+ /* `pages: null` means "the whole registry" and is what the skill library
+ wants. An agent always passes its scope, and gets only what it could
+ actually use — see `compatibleSkills`. */
+ const source = pages
+ ? compatibleSkills(pages, customSkills)
+ : aiAgentSkills(allSkills(customSkills));
+
+ return skillsWithFacet(source, 'owliver')
+ .filter((s) => s.status === 'active')
+ .map(catalogEntry)
+ .sort((a, b) => a.name.localeCompare(b.name));
+}
+
+/* ── Scope, and what is compatible with it ────────────────────────────────
+ *
+ * The fix for the catalog that offered every agent all eighteen skills.
+ *
+ * An agent is not a container you may put anything in. It answers on a set of
+ * surfaces, a skill declares the surfaces it answers on, and the overlap is the
+ * only set that can ever do anything. Offering Create Position to the Activity
+ * Agent was not merely noisy — it was offering an attachment the runtime would
+ * then refuse, because `skillsForContext` filters by page *before* an agent's
+ * own list is ever consulted. The catalog was advertising attachments that
+ * could not work.
+ *
+ * **One function over declared data, not eight lists.** `getSkillsForPage` is
+ * the registry's own answer to "what belongs here" and is what the live
+ * assistant already routes through, so the catalog and the runtime cannot
+ * disagree by construction. Nothing below tests an agent by name, and a surface
+ * or a skill added tomorrow is scoped correctly without this file being edited.
+ */
+
+/** The surfaces an agent answers on, canonicalized so aliases resolve. */
+export const scopeOf = (pages = []) =>
+ [...new Set(pages.map((p) => canonicalPage(p) || p).filter(Boolean))];
+
+/**
+ * Every skill compatible with a scope — the union over its surfaces.
+ *
+ * Union rather than intersection: an agent covering Positions and Create
+ * Position may use a skill answering on either, which is exactly what the
+ * runtime does when the reader is standing on one of them.
+ */
+export function compatibleSkills(pages = [], customSkills = []) {
+ const seen = new Map();
+ for (const page of scopeOf(pages)) {
+ for (const skill of getSkillsForPage(page, { customSources: customSkills, kind: 'assistant' })) {
+ if (!seen.has(skill.id)) seen.set(skill.id, skill);
+ }
+ }
+ return [...seen.values()];
+}
+
+/** Is this capability usable by an agent with this scope? */
+export const isCompatible = (skillId, pages = [], customSkills = []) =>
+ compatibleSkills(pages, customSkills).some((s) => s.id === skillId);
+
+/**
+ * Which surfaces a capability offers itself on — Owliver, Board, or both.
+ *
+ * Read off the facets the parser already derives from what the definition
+ * declares. There is deliberately **no new `capabilityType:` field**: a second
+ * place to say the same thing is a second place for it to be wrong, and a
+ * definition that gains an `owliver:` block tomorrow becomes `both` on its own.
+ * One logical capability, two surfaces — never two definitions.
+ */
+export function capabilityType(skill) {
+ const owliver = skill.facets?.includes('owliver');
+ const board = skill.facets?.includes('ui');
+ if (owliver && board) return 'both';
+ return board ? 'board' : 'owliver';
+}
+
+/** The type, in the words the detail panel uses. */
+export const TYPE_LABEL = { owliver: 'Owliver', board: 'Board', both: 'Owliver + Board' };
+
+
+/* ── Board skills ─────────────────────────────────────────────────────────
+ *
+ * The registry's other facet, read the same way. A Board skill draws a section
+ * on a KROW page; an Owliver skill teaches the assistant what it can be asked.
+ * One registry, one parser, two readings — exactly the split
+ * `WorkspaceSkills` already manages, surfaced here so the agent editor can show
+ * both under one Skills heading without either becoming a second system.
+ *
+ * **The relationship is genuinely different, and this module says so rather
+ * than flattening it.** An Owliver skill is carried by an agent: `agent.skills`
+ * decides what that agent may use, and `agentScopedDisabled` enforces it. A
+ * Board skill is not — `SkillSurface` renders sections from the page and the
+ * account's `disabledSkills`, and consults no agent at all. So a Board entry
+ * carries the surfaces it draws on and whether it is switched on, and never an
+ * "attached to this agent" flag, because there is nothing behind one.
+ */
+
+/** The sections a Board skill declares, flattened with the page each sits on. */
+function sectionsOf(skill) {
+ return Object.entries(skill.ui || {}).flatMap(([page, config]) =>
+ (config.sections || []).map((section) => ({
+ id: section.id,
+ title: section.title || sectionTypeLabel(section.type),
+ type: section.type,
+ typeLabel: sectionTypeLabel(section.type),
+ page,
+ pageLabel: surfaceFor(page)?.label || page,
+ placement: section.placement,
+ placementLabel: placementLabel(section.placement),
+ source: section.source || null,
+ }))
+ );
+}
+
+/** One Board skill, as the agent editor needs to read it. */
+export function boardEntry(skill) {
+ const group = groupFor(skill);
+ const sections = sectionsOf(skill);
+ return {
+ id: skill.id,
+ name: skill.name,
+ description: skill.description,
+ group,
+ groupLabel: groupLabel(group),
+ type: capabilityType(skill),
+ sections,
+ /** The pages this skill actually draws on, derived from its sections. */
+ surfaces: [...new Map(sections.map((s) => [s.page, { id: s.page, label: s.pageLabel }])).values()],
+ custom: Boolean(skill.custom),
+ status: skill.status,
+ };
+}
+
+/** Every Board skill in the registry, as entries. */
+export function boardCatalog(customSkills = [], { pages = null } = {}) {
+ const source = pages
+ ? compatibleSkills(pages, customSkills)
+ : aiAgentSkills(allSkills(customSkills));
+
+ return skillsWithFacet(source, 'ui')
+ .filter((s) => s.status === 'active')
+ .map(boardEntry)
+ .sort((a, b) => a.name.localeCompare(b.name));
+}
+
+/**
+ * Does this Board skill draw on any surface the agent covers?
+ *
+ * The true relationship between an agent and a Board skill, and the only one
+ * there is: they can meet on a page. An agent that answers on Positions stands
+ * beside whatever Board sections Positions renders — it does not own them, and
+ * it cannot switch them on.
+ */
+export const boardMeetsAgent = (entry, pages = []) => {
+ if (!pages.length) return false;
+ const covered = new Set(pages);
+ return entry.surfaces.some((s) => covered.has(s.id));
+};
+
+/** Board entries, filtered by the same query the Owliver catalog uses. */
+export function filterBoard(entries, { query = '' } = {}) {
+ const q = String(query || '').trim().toLowerCase();
+ if (!q) return entries;
+ return entries.filter((e) => [
+ e.name, e.description, e.groupLabel, e.id,
+ ...e.surfaces.map((s) => s.label),
+ ...e.sections.map((s) => `${s.title} ${s.typeLabel} ${s.placementLabel}`),
+ ].join(' ').toLowerCase().includes(q));
+}
+
+/**
+ * The groups this catalog actually contains, in a stable order.
+ *
+ * Derived rather than written down, so a filter can never offer a heading that
+ * matches nothing — and a definition declaring a category nobody anticipated
+ * appears under it instead of vanishing into "everything else".
+ */
+export function catalogGroups(entries = []) {
+ const counts = new Map();
+ for (const entry of entries) {
+ counts.set(entry.group, (counts.get(entry.group) || 0) + 1);
+ }
+
+ const known = SKILL_GROUPS
+ .filter((g) => counts.has(g.id))
+ .map((g) => ({ ...g, count: counts.get(g.id) }));
+
+ const extra = [...counts.keys()]
+ .filter((id) => !GROUP_BY_ID.has(id))
+ .sort()
+ .map((id) => ({ id, label: groupLabel(id), blurb: '', count: counts.get(id) }));
+
+ return [...known, ...extra];
+}
+
+/**
+ * Does this entry match what was typed?
+ *
+ * Name, description, group, the surfaces it answers on, the capabilities it
+ * offers and its id — so "attendance", "Control Center", "table" and
+ * "anomaly-detection" all find something, and a reader who knows the domain
+ * rather than the catalog can still search it.
+ */
+export function matchesQuery(entry, query) {
+ const q = String(query || '').trim().toLowerCase();
+ if (!q) return true;
+
+ const haystack = [
+ entry.name,
+ entry.description,
+ entry.groupLabel,
+ entry.id,
+ ...entry.surfaces.map((s) => s.label),
+ ...entry.capabilities.shapes,
+ ...entry.capabilities.described,
+ ...entry.tools.map((t) => t.label),
+ ].join(' ').toLowerCase();
+
+ return haystack.includes(q);
+}
+
+/**
+ * The catalog, filtered.
+ *
+ * One function so the count shown beside a filter and the cards under it are
+ * the same reading — two filters written separately is how a heading comes to
+ * say "6 skills" above four cards.
+ */
+export function filterCatalog(entries, { query = '', group = 'all' } = {}) {
+ return entries
+ .filter((e) => group === 'all' || e.group === group)
+ .filter((e) => matchesQuery(e, query));
+}
diff --git a/src/lib/skills/registry.js b/src/lib/skills/registry.js
index c792215..ca70acb 100644
--- a/src/lib/skills/registry.js
+++ b/src/lib/skills/registry.js
@@ -735,7 +735,7 @@ export function contextIdsForSkill(skill) {
* Nothing downstream may test a page name against a skill id. The moment a page
* asks "is this Server Training?" the attachment has stopped being data.
*/
-export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
+export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind = undefined } = {}) {
if (!pageId) return [];
const wanted = canonicalPage(pageId) || pageId;
return allSkills(customSources).filter(
diff --git a/src/lib/skills/surfaces.js b/src/lib/skills/surfaces.js
index 71f1b79..340d48a 100644
--- a/src/lib/skills/surfaces.js
+++ b/src/lib/skills/surfaces.js
@@ -250,6 +250,22 @@ for (const surface of SKILL_SURFACES) {
}
/** Every name a definition may use for a surface, for error messages. */
+/**
+ * The surfaces that are product *domains* rather than configuration screens.
+ *
+ * Read off `placements`, which is already the honest distinction: a domain
+ * surface offers places for a section to sit because it holds workforce
+ * records, and a configuration screen offers none because it holds none. That
+ * is why Settings, Workspace and the two editors declare `placements: []`.
+ *
+ * The agent editor uses this so its scope picker offers the pages an agent
+ * could sensibly answer *about*, instead of every route the vocabulary happens
+ * to name. Derived, so a surface added tomorrow classifies itself.
+ */
+export const DOMAIN_SURFACES = SKILL_SURFACES
+ .filter((s) => s.placements.length > 0)
+ .map((s) => s.id);
+
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
/**
diff --git a/src/pages/admin/AgentDetail.jsx b/src/pages/admin/AgentDetail.jsx
index 7343e29..fb3260f 100644
--- a/src/pages/admin/AgentDetail.jsx
+++ b/src/pages/admin/AgentDetail.jsx
@@ -7,14 +7,18 @@ import {
import { cn } from '@/lib/utils';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
-import { usePreferences } from '@/lib/krowHooks';
+import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
+import { reportSave } from '@/lib/skills/saveFeedback';
import { useAgents } from '@/lib/agents/useAgents';
import { agentTemplate } from '@/lib/agents/customAgents';
import { agentFieldsFromSource, applyAgentFields } from '@/lib/agents/agentFields';
import { validateAgentSource } from '@/lib/agents/registry';
import { AgentConfigure } from '@/components/agents/AgentConfigure';
+import { AgentSkillWorkspace } from '@/components/agents/skills/AgentSkillWorkspace';
import { AgentTestPanel } from '@/components/agents/AgentTestPanel';
import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
+import { useAssistantPanel } from '@/components/ai-assistant';
+import { boardCatalog, skillCatalog } from '@/lib/skills/catalog';
/**
* One agent, configured.
@@ -31,6 +35,44 @@ import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
const STATUS_TONE = { published: 'success', draft: 'neutral', archived: 'warning' };
+/**
+ * What Owliver says when a skill joins or leaves an agent.
+ *
+ * Composed from the definition itself — its name, its own description, and the
+ * questions it declares — so the sentence cannot promise a capability the skill
+ * does not have. Nothing is generated: this is the workspace stating a fact in
+ * the conversation, and the chips under it are the skill's own suggestions, so
+ * the next step is a question that skill was written to answer.
+ */
+function skillNotice({ entry, id, agentName, attached }) {
+ const name = entry?.name || id;
+ const on = agentName ? `**${agentName}**` : 'this agent';
+
+ if (!attached) {
+ return {
+ text: `**${name}** has been removed from ${on}. It no longer answers here.`,
+ followUp: null,
+ };
+ }
+
+ const opening = `**${name}** is now available to ${on}.`;
+ const what = entry?.description ? ` ${entry.description}` : '';
+ const invitation = entry?.suggestions?.length
+ ? ' Ask me one of these, or anything else it covers.'
+ : ' Ask me about it whenever you need it.';
+
+ return {
+ text: `${opening}${what}${invitation}`,
+ followUp: entry?.suggestions?.length
+ ? entry.suggestions.slice(0, 3).map((s) => ({
+ label: s.label,
+ prompt: s.prompt,
+ ...(s.capability ? { capability: s.capability } : null),
+ }))
+ : null,
+ };
+}
+
export default function AdminAgentDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -53,6 +95,50 @@ export default function AdminAgentDetail() {
const [fields, setFields] = useState(() => (baseSource ? agentFieldsFromSource(baseSource) : null));
const [dirty, setDirty] = useState(false);
const [view, setView] = useState('configure');
+ /* The skill currently being written, so its card can say so and a second
+ click cannot race the first. */
+ const [pendingSkill, setPendingSkill] = useState(/** @type {string|null} */ (null));
+
+ /* The same catalog the workspace renders, read here so the sentence Owliver
+ says about a skill comes from the definition rather than from the card. */
+ const customSkills = useMemo(() => preferences.customSkills || [], [preferences.customSkills]);
+ const catalog = useMemo(() => skillCatalog(customSkills), [customSkills]);
+
+ /* Owliver's existing panel, on this page already. Attaching a skill states a
+ fact in that conversation — it never opens a second one. */
+ const { announce, ask, test } = useAssistantPanel();
+
+ /**
+ * Trying a capability, in the panel already on this page.
+ *
+ * `scope` runs the turn against the surface the capability actually answers
+ * on — see `useConversation.send`. Nothing is written: this is the whole of
+ * "test", and the agent is unchanged until Enable is pressed.
+ */
+ const onTestCapability = useCallback(({ question, scope, capability, trace }) => {
+ ask({ question, scope, capability, trace });
+ }, [ask]);
+
+ /* Board skills are governed by the account's `disabledSkills` — the one list
+ `SkillSurface` actually reads. Written through the same `updatePreferences`
+ the Skills page uses, so there is no second persistence for it. */
+ const updatePreferences = useUpdatePreferences();
+ const disabledSkills = useMemo(
+ () => preferences.disabledSkills || [],
+ [preferences.disabledSkills]
+ );
+
+ const onToggleBoardSkill = useCallback((skillId, enabled) => {
+ const entry = boardCatalog(customSkills).find((e) => e.id === skillId);
+ const next = enabled
+ ? disabledSkills.filter((x) => x !== skillId)
+ : [...new Set([...disabledSkills, skillId])];
+
+ updatePreferences.mutate(
+ { disabledSkills: next },
+ reportSave(`${entry?.name || skillId} ${enabled ? 'enabled' : 'disabled'}`)
+ );
+ }, [customSkills, disabledSkills, updatePreferences]);
/* Reload when the address changes, but never overwrite an edit in progress. */
useEffect(() => {
@@ -86,6 +172,82 @@ export default function AdminAgentDetail() {
return result.agent;
}, [fields, baseSource, save, creating, navigate]);
+ /**
+ * Attaching and detaching a skill.
+ *
+ * The one write path for `fields.skills`, and deliberately not the same one
+ * the rest of the form uses. Every other field is a *draft* until Save — a
+ * half-typed name should never reach Owliver. A skill is not like that: the
+ * whole point of the catalog is that the agent gains the capability there and
+ * then, and the tree, the card and Owliver all say so immediately.
+ *
+ * So it is optimistic, through the existing `save`, with one honest exception
+ * and one honest revert:
+ *
+ * - **A draft the author is still editing is left a draft.** With unsaved
+ * changes on screen — or a brand-new agent with no definition yet — a
+ * write here would silently persist a half-finished name along with the
+ * skill. The change joins the draft instead, and the header already says
+ * the agent has unsaved changes.
+ * - **A refused write is put back.** The list is restored to exactly what it
+ * was and the reason is shown, rather than leaving a tree claiming a skill
+ * the stored definition does not carry.
+ */
+ const onToggleSkill = useCallback(async (skillId) => {
+ if (!fields || pendingSkill) return;
+
+ const attached = !fields.skills.includes(skillId);
+ const previous = fields.skills;
+ const nextSkills = attached
+ /* Never a duplicate: the id is added only when it is absent, so clicking
+ Add twice cannot list the same skill twice. */
+ ? [...previous, skillId]
+ : previous.filter((x) => x !== skillId);
+
+ const nextFields = { ...fields, skills: nextSkills };
+ const notice = skillNotice({
+ entry: catalog.find((e) => e.id === skillId) || null,
+ id: skillId,
+ agentName: fields.name,
+ attached,
+ });
+
+ /* A draft stays a draft — see above. */
+ if (creating || dirty || !agent) {
+ setFields(nextFields);
+ setDirty(true);
+ announce(notice);
+ return;
+ }
+
+ setFields(nextFields);
+ setPendingSkill(skillId);
+
+ const composed = applyAgentFields(baseSource, nextFields);
+ const problem = validateAgentSource(composed);
+ if (problem) {
+ setFields((current) => ({ ...current, skills: previous }));
+ setPendingSkill(null);
+ toast.error(problem);
+ return;
+ }
+
+ const skillName = catalog.find((e) => e.id === skillId)?.name || skillId;
+ const result = await save(composed, {
+ message: attached ? `${skillName} attached` : `${skillName} removed`,
+ });
+
+ setPendingSkill(null);
+
+ if (!result.ok) {
+ setFields((current) => ({ ...current, skills: previous }));
+ toast.error(result.error);
+ return;
+ }
+
+ announce(notice);
+ }, [fields, pendingSkill, creating, dirty, agent, catalog, baseSource, save, announce]);
+
const onPublish = useCallback(async () => {
const stored = dirty ? await persist() : agent;
if (!stored) return;
@@ -230,7 +392,9 @@ export default function AdminAgentDetail() {
)}
setView('skills')}
+ scopeLocked={shipped}
+ />
+ )}
+ {view === 'skills' && (
+
)}
{view === 'test' && (
)}
{view === 'insights' && (
diff --git a/src/pages/admin/SkillDevelopment.jsx b/src/pages/admin/SkillDevelopment.jsx
index 505689f..caecdf3 100644
--- a/src/pages/admin/SkillDevelopment.jsx
+++ b/src/pages/admin/SkillDevelopment.jsx
@@ -198,7 +198,7 @@ export default function AdminSkillDevelopment() {
actions={
<>
navigate('/admin/workspace')}>
- Workspace
+ Agent Registry
navigate('/admin/university')}>
Manage training
diff --git a/src/pages/admin/Workspace.jsx b/src/pages/admin/Workspace.jsx
index dd5db94..75408b5 100644
--- a/src/pages/admin/Workspace.jsx
+++ b/src/pages/admin/Workspace.jsx
@@ -1,35 +1,39 @@
import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
-import {
- LayoutTemplate, Sparkles,
- BookOpen, ArrowRight
-} from 'lucide-react';
+import { ArrowRight, BookOpen, Sparkles } from 'lucide-react';
import { usePreferences } from '@/lib/krowHooks';
-import { aiAgentSkills, allSkills, skillsWithFacet } from '@/lib/skills/registry';
+import { aiAgentSkills, allSkills } from '@/lib/skills/registry';
import { allAgents } from '@/lib/agents/registry';
import { AdminPage } from '@/components/admin/PageShell';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
-import { cn } from '@/lib/utils';
/**
- * Workspace & Skills — AI agent capability governance.
+ * Workspace — the way in to AI configuration, and only that.
*
- * This page governs what *Owliver* can do. It used to govern workforce training
- * as well — Training Paths and Skill Progression sat in the right-hand column —
- * and that conflated two unrelated things under one word. A "skill" here is a
- * capability the assistant gains; a "skill" in KROW Forge is something a person
- * learns, proves and is verified in. They have different owners, different
- * lifecycles and different audiences, and putting them side by side made the page
- * read as though enabling a Bartending path taught Owliver to tend bar.
+ * This page used to offer three destinations side by side: Owliver Agents,
+ * Owliver Skills, and Board Skills. Each was a real thing with a real page
+ * behind it, and together they asked the reader a question the product should
+ * never have asked — *which of these three am I supposed to open?* Nothing on
+ * screen answered it, because the answer is structural: skills are configured
+ * **on an agent**, and an agent is the only thing here anyone actually manages.
*
- * Workforce training was not removed, only returned to where it belongs: the
- * definitions, the routes and the pages are untouched, and KROW Forge
- * (`/admin/university`) and Skill Development still read them. See
- * `workforceTrainingSkills` in the registry for the other half of the split.
+ * So there is one destination now. Agents. Everything else is reached through
+ * the agent it belongs to:
+ *
+ * Workspace → Agents → Configure → Skills → Owliver | Board
+ *
+ * Nothing was deleted to achieve that. `/admin/workspace/skills` still exists
+ * and still authors definitions — it is where the agent editor's own links go —
+ * it simply stopped being advertised as a peer of the thing that uses it.
+ *
+ * The counts are the same registry reads as before, minus the one that named an
+ * implementation detail: "Skill Kinds" told a recruiter that the product has two
+ * internal facets, which is true and is not their problem.
*/
export default function AdminWorkspace() {
const preferences = usePreferences();
+
/* Read through the agent registry, never by counting skills — one registry
answers "how many agents", exactly as the skill registry answers its own. */
const agentCount = useMemo(
@@ -53,220 +57,112 @@ export default function AdminWorkspace() {
const disabledSkills = preferences.disabledSkills || [];
const activeSkills = skills.filter((s) => !disabledSkills.includes(s.id)).length;
- /* The two kinds of agent skill — the same two lists the management page is
- split into, read here from the same filtered registry so the landing page and
- the manager can never disagree about what is registered. */
- const owliverSkills = useMemo(() => skillsWithFacet(skills, 'owliver'), [skills]);
- const boardSkills = useMemo(() => skillsWithFacet(skills, 'ui'), [skills]);
- const owliverCount = owliverSkills.length;
- const boardCount = boardSkills.length;
+ const metrics = [
+ { label: 'Agents', value: agentCount },
+ { label: 'Skills', value: skills.length },
+ { label: 'Active', value: activeSkills },
+ ];
return (
-
- {/* ── 1. Hero Banner (Full Width) ───────────────────────────────── */}
-
-
-
-
-
-
- AI Capability Governance
+
+ {/* ── Hero ───────────────────────────────────────────────────────
+ The same banner, saying three plain numbers instead of two figures
+ and a word only this codebase uses. */}
+
+
+
+
+
+
+ AI Agent Registry
-
AI Agent Capabilities
-
- Govern what Owliver can understand, analyze and execute. Workforce training
- paths and talent progression are managed in KROW Forge.
+
+ AI Agent Registry
+
+
+ Manage the agents that power Owliver across Krow. Each agent decides which
+ pages it answers on and which skills it may use.
-
-
Registered Skills
-
{skills.length}
-
{activeSkills} Active
-
- {/* Was "Training Paths". Replaced rather than dropped: the slot is
- useful, and the split of agent skills across the two lists is
- what this page actually governs. */}
-
-
Skill Kinds
-
{owliverCount + boardCount}
-
{owliverCount} Owliver · {boardCount} Board
-
+ {metrics.map((metric) => (
+
+
+ {metric.label}
+
+
{metric.value}
+
+ ))}
- {/* ── Agents ─────────────────────────────────────────────────────
- Above the two skill columns because it is the layer over them: an
- agent decides which of these skills Owliver may use on a page. */}
-
-
-
-
-
Owliver Agents
+ {/* ── The one destination ────────────────────────────────────────
+ Skills are not a card beside this one. They are configured on an
+ agent, and this is the door to the agents. */}
+
+
+
+
+
Agents
-
+
{agentCount} Registered
- An agent is how Owliver answers on a page: which skills it may use, what it knows, and
- how much work an answer is worth. Agents narrow what a page offers — they never widen it.
+ An agent is how Owliver answers on a page: which skills it may use, what it knows,
+ and how much work an answer is worth. Agents narrow what a page offers — they never
+ widen it.
+ {/* The hierarchy, said once, so the reader knows where skills live
+ before they click rather than after. */}
+
+
Agents
+
+
Configure
+
+
Skills
+
+
Owliver Skills
+
/
+
Board Skills
+
+
-
+
Manage Agents
-
+
- {/* ── 2. Full Width 2-Column Grid ───────────────────────────────── */}
-
- {/* ── Left Column: Owliver AI Capabilities ───────────────────── */}
-
-
-
-
-
-
Owliver Skills
-
-
- {owliverCount} Registered
-
-
-
-
- Owliver skills teach the assistant what it can be asked on a page and how to answer —
- screening, matching and contextual analysis. They define what the AI understands, not
- what a candidate learns.
-
-
- {/* Skills Sample List */}
-
- {owliverSkills.slice(0, 4).map((s) => {
- const isActive = !disabledSkills.includes(s.id);
- return (
-
-
-
{s.name || s.id}
- {s.description &&
{s.description}
}
-
-
- {isActive ? 'Active' : 'Disabled'}
-
-
- );
- })}
-
-
- {/* CTA Button */}
-
-
-
- Manage Owliver Skills
-
-
-
-
-
-
- {/* ── Right Column: Board Skills ─────────────────────────────────
- The other kind of AI agent skill. This column previously held
- Workforce Training Paths and Skill Progression Tracking, both of
- which are KROW Forge concerns — see the note at the top of the file.
- Board skills belong here because, like Owliver skills, they are
- capabilities the product gains rather than something a person
- learns. */}
-
-
-
-
-
-
Board Skills
-
-
- {boardCount} Registered
-
-
-
-
- Board skills add dynamic sections to KROW pages, read from real page data. They
- extend what a page shows, not what a candidate learns.
-
-
- {/* Sample list, from the same filtered registry read as the left
- column — so the two columns can never disagree about what is
- registered. */}
-
- {boardSkills.slice(0, 4).map((s) => {
- const isActive = !disabledSkills.includes(s.id);
- return (
-
-
-
{s.name || s.id}
- {s.description &&
{s.description}
}
-
-
- {isActive ? 'Active' : 'Disabled'}
-
-
- );
- })}
-
- {boardSkills.length === 0 && (
-
- No Board skills registered yet. A Board skill declares a section, where it
- sits and what it reads.
-
- )}
-
-
-
-
-
- Manage Board Skills
-
-
-
-
-
-
-
- {/* ── 3. Unified Registry Architecture Banner ───────────────────── */}
-
-
-
+ {/* ── The one distinction worth keeping ──────────────────────────
+ Not a third destination: a standing fact about a word that means two
+ unrelated things in this product, and the reason KROW Forge is not
+ on this page. */}
+
+
+
AI skills and workforce training are different things
-
+
This page governs AI agent skills — what Owliver
- understands and what a Board skill draws on a page. Workforce
- training — training paths, proving challenges, skill verification and talent
+ understands, and what a Board skill draws on a page. Workforce
+ training — training paths, proving challenges, verification and talent
progression — is what a person learns, and it is managed in{' '}
KROW Forge.
- Both are Markdown in one registry, read through separate queries so neither appears in the
- other’s surface.
diff --git a/src/pages/admin/WorkspaceAgents.jsx b/src/pages/admin/WorkspaceAgents.jsx
index 1befab3..b1a574d 100644
--- a/src/pages/admin/WorkspaceAgents.jsx
+++ b/src/pages/admin/WorkspaceAgents.jsx
@@ -469,7 +469,7 @@ export default function AdminWorkspaceAgents() {
className="hidden md:inline-flex items-center gap-1.5 rounded-lg border border-border/70 bg-surface px-3 py-1.5 text-body-sm text-ink-3 transition-colors hover:border-krow-blue/40 hover:text-krow-blue hover:bg-surface-subtle"
>
Skills managed in
-
Workspace → Skills
+
Agent Registry → Skills
navigate('/admin/workspace')}>
- Workspace
+ Agent Registry
{/* Account definitions live in this browser's storage and nowhere
else. Import and export are what make that a place rather than a
diff --git a/vite.config.js b/vite.config.js
index dd9458a..7019f91 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -58,10 +58,9 @@ export default defineConfig({
*/
proxy: {
'/api': {
- target: process.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:8080',
- // The API does not route on Host, and rewriting it would make the
- // Origin the backend sees disagree with the one the browser sent.
- changeOrigin: false,
+ target: process.env.VITE_API_PROXY_TARGET || 'https://mcp.korwfoce.com',
+ changeOrigin: true,
+ secure: false,
},
},
},