diff --git a/packages/@n8n/api-types/src/agents/__tests__/skill-names.test.ts b/packages/@n8n/api-types/src/agents/__tests__/skill-names.test.ts
new file mode 100644
index 00000000000..02a2c0d42db
--- /dev/null
+++ b/packages/@n8n/api-types/src/agents/__tests__/skill-names.test.ts
@@ -0,0 +1,17 @@
+import { skillNameKey } from '../skill-names';
+
+describe('skillNameKey', () => {
+ it.each([
+ ['Brand voice', 'brand-voice'],
+ ['Brand voice', 'brand_voice'],
+ ['Brand voice', ' BRAND voice '],
+ ['Brand voice', 'brand -_ voice'],
+ ])('treats "%s" and "%s" as the same name', (left, right) => {
+ expect(skillNameKey(left)).toBe(skillNameKey(right));
+ });
+
+ it('keeps other characters apart', () => {
+ expect(skillNameKey('Brand voice')).not.toBe(skillNameKey('Brand voices'));
+ expect(skillNameKey('brand.voice')).not.toBe(skillNameKey('brand voice'));
+ });
+});
diff --git a/packages/@n8n/api-types/src/agents/index.ts b/packages/@n8n/api-types/src/agents/index.ts
index 08cabe0d9eb..f5e86f490bd 100644
--- a/packages/@n8n/api-types/src/agents/index.ts
+++ b/packages/@n8n/api-types/src/agents/index.ts
@@ -8,6 +8,7 @@ export * from './agent-node-tool-operations';
export * from './agent-personalisation';
export * from './agent-skill.schema';
export * from './skills.schema';
+export * from './skill-names';
export * from './child-trace';
export type * from './background-job';
export * from './inline-agent-config.schema';
diff --git a/packages/@n8n/api-types/src/agents/skill-names.ts b/packages/@n8n/api-types/src/agents/skill-names.ts
new file mode 100644
index 00000000000..a297813ebb8
--- /dev/null
+++ b/packages/@n8n/api-types/src/agents/skill-names.ts
@@ -0,0 +1,11 @@
+/**
+ * The rule for two skill names that are hard to tell apart inside one agent: case,
+ * surrounding spaces, and runs of spaces, hyphens and underscores do not count.
+ */
+export function skillNameKey(name: string): string {
+ return name
+ .trim()
+ .toLowerCase()
+ .replace(/[\s_-]+/g, ' ')
+ .trim();
+}
diff --git a/packages/cli/src/modules/agents/__tests__/skill-names.test.ts b/packages/cli/src/modules/agents/__tests__/skill-names.test.ts
index a0158e4ef7b..e6a3ba86713 100644
--- a/packages/cli/src/modules/agents/__tests__/skill-names.test.ts
+++ b/packages/cli/src/modules/agents/__tests__/skill-names.test.ts
@@ -2,25 +2,8 @@ import {
findIntroducedNameClashes,
findNameClashGroups,
renameIntroducesClash,
- skillNameKey,
} from '../skills/skill-names';
-describe('skillNameKey', () => {
- it.each([
- ['Brand voice', 'brand-voice'],
- ['Brand voice', 'brand_voice'],
- ['Brand voice', ' BRAND voice '],
- ['Brand voice', 'brand -_ voice'],
- ])('treats "%s" and "%s" as the same name', (left, right) => {
- expect(skillNameKey(left)).toBe(skillNameKey(right));
- });
-
- it('keeps other characters apart', () => {
- expect(skillNameKey('Brand voice')).not.toBe(skillNameKey('Brand voices'));
- expect(skillNameKey('brand.voice')).not.toBe(skillNameKey('brand voice'));
- });
-});
-
describe('findIntroducedNameClashes', () => {
it('returns an added name that clashes with an existing name', () => {
expect(findIntroducedNameClashes(['Brand voice'], ['brand-voice', 'Pricing'])).toEqual([
diff --git a/packages/cli/src/modules/agents/skills/skill-names.ts b/packages/cli/src/modules/agents/skills/skill-names.ts
index 5a569890cbd..9a5ace137cf 100644
--- a/packages/cli/src/modules/agents/skills/skill-names.ts
+++ b/packages/cli/src/modules/agents/skills/skill-names.ts
@@ -1,14 +1,4 @@
-/**
- * The rule for two skill names that are hard to tell apart inside one agent: case,
- * surrounding spaces, and runs of spaces, hyphens and underscores do not count.
- */
-export function skillNameKey(name: string): string {
- return name
- .trim()
- .toLowerCase()
- .replace(/[\s_-]+/g, ' ')
- .trim();
-}
+import { skillNameKey } from '@n8n/api-types';
/**
* Names in `added` that clash with a name in `existing` or with an earlier added name.
diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index 7b0f5b7c9be..509517ffafd 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -8851,8 +8851,7 @@
"agents.builder.tools.type.custom": "Custom tool",
"agents.builder.tools.remove": "Remove",
"agents.builder.tools.back": "Back to tools",
- "agents.builder.skills.outdated.title": "This agent uses an older version",
- "agents.builder.skills.outdated.warning": "This agent uses an older version of this skill. To get the latest version, remove the skill and add it again.",
+ "agents.builder.skills.outdated.warning": "This agent uses an older version of this skill. Open the skill to use the latest version.",
"agents.builder.skills.title": "Skills",
"agents.builder.skills.description": "How this agent handles specific tasks",
"agents.builder.skills.count": "{count} skill applied | {count} skills applied",
@@ -8910,17 +8909,42 @@
"agents.builder.skills.references.characterCount": "{count} / {max} characters",
"agents.builder.skills.create.error": "Could not create skill",
"agents.builder.skills.saveError": "Could not save skill",
- "agents.builder.skills.duplicateName.error": "Agent already has a skill named \"{name}\"",
+ "agents.builder.skills.duplicateName.error": "Agent already has a skill with a name like \"{name}\"",
"agents.builder.skills.validation.nameRequired": "Enter a name.",
"agents.builder.skills.validation.nameMaxLength": "Name must be 128 characters or fewer.",
- "agents.builder.skills.validation.nameDuplicate": "Agent already has a skill with this name.",
+ "agents.builder.skills.validation.nameDuplicate": "Agent already has a skill with a name like this.",
"agents.builder.skills.validation.descriptionRequired": "Enter a description.",
"agents.builder.skills.validation.descriptionMaxLength": "Description must be 512 characters or fewer.",
"agents.builder.skills.validation.instructionsRequired": "Enter instructions.",
"agents.builder.skills.validation.instructionsMaxLength": "Instructions must be {max} characters or fewer.",
"agents.builder.skills.missingContent.callout": "This skill's content is missing. Fill it in and save to recreate it.",
"agents.builder.skills.remove": "Remove skill",
+ "agents.builder.skills.detach": "Detach",
"agents.builder.skills.back": "Back to skills",
+ "agents.builder.skills.picker.search.placeholder": "Search skills",
+ "agents.builder.skills.picker.create": "Create skill",
+ "agents.builder.skills.picker.create.description": "Write a new skill for this agent",
+ "agents.builder.skills.picker.add": "Add skill",
+ "agents.builder.skills.picker.loading": "Loading skills…",
+ "agents.builder.skills.picker.empty": "No skills to add. Create one, or ask an admin to share one with this project.",
+ "agents.builder.skills.picker.noResults": "No skills match your search",
+ "agents.builder.skills.picker.loadError": "Could not load skills",
+ "agents.builder.skills.picker.previewError": "Could not load the skill",
+ "agents.builder.skills.accessError": "Could not check if you can edit this skill",
+ "agents.builder.skills.picker.similarName": "Skills {names} have names that are hard to tell apart. Rename one of them.",
+ "agents.builder.skills.attach.error": "Could not add skill",
+ "agents.builder.skills.availableTo.label": "Where can this skill be added?",
+ "agents.builder.skills.availableTo.tooltip": "This project: agents in this project can add it. This agent only: only this agent can use it. Global: agents in all projects can add it.",
+ "agents.builder.skills.availableTo.project": "This project",
+ "agents.builder.skills.availableTo.agent": "This agent only",
+ "agents.builder.skills.availableTo.instance": "Global",
+ "agents.builder.skills.availableTo.locked": "Other agents use this skill. Detach it from those agents to change where it can be added. For agents that you cannot open, ask an admin.",
+ "agents.builder.skills.hint.addToEdit": "Add this skill to the agent to edit it.",
+ "agents.builder.skills.hint.outdated": "This agent uses an older version of this skill.",
+ "agents.builder.skills.useLatest": "Use latest version",
+ "agents.builder.skills.useLatest.error": "Could not switch to the latest version",
+ "agents.builder.skills.hint.readOnly.instance": "You can't edit this skill. Ask an instance admin to change it.",
+ "agents.builder.skills.hint.readOnly.project": "You can't edit this skill. Ask a project admin to change it.",
"agents.builder.tasks.title": "Schedules",
"agents.builder.tasks.description": "Objectives the agent runs on a schedule once it's published.",
"agents.builder.tasks.add": "Add schedule",
@@ -9432,9 +9456,14 @@
"tools.connection.categories.community": "Community",
"tools.connection.categories.workflows": "Workflows",
"tools.connection.categories.agents": "Agents",
+ "tools.connection.categories.project": "Project",
+ "tools.connection.categories.thisAgent": "This agent",
+ "tools.connection.categories.global": "Global",
"tools.connection.categories.data": "Data",
"tools.connection.action.connect": "Connect",
"tools.connection.action.connected": "Connected",
+ "tools.connection.action.attached": "Attached",
+ "tools.connection.action.detached": "Detached",
"tools.connection.action.connecting": "Connecting…",
"tools.connection.action.reconnect": "Reconnect",
"tools.connection.action.configure": "Configure",
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts
index 36a2589de5b..a5a6fcc8ef5 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts
@@ -5658,6 +5658,7 @@ describe('AgentBuilderView — three-column shell', () => {
'summarize_notes',
updatedSkill,
'skill-hash-1',
+ undefined,
);
});
@@ -5710,6 +5711,7 @@ describe('AgentBuilderView — three-column shell', () => {
instructions: skill.instructions,
},
'skill-hash-1',
+ undefined,
);
});
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts
index 50a10d8bd7c..0b4ffa2f0f7 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ref } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
@@ -7,9 +8,11 @@ import { configure, fireEvent, waitFor } from '@testing-library/vue';
import {
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
AGENT_SKILL_REFERENCE_MAX_COUNT,
+ type SkillDetail,
+ type SkillListItem,
} from '@n8n/api-types';
-import AgentSkillModal from '../components/AgentSkillModal.vue';
+import AgentSkillModal, { type AgentSkillModalData } from '../components/AgentSkillModal.vue';
import type { AgentSkill } from '../types';
import { AgentModalTestStub } from './utils/AgentModalTestStub';
@@ -23,16 +26,52 @@ vi.mock('@n8n/i18n', () => {
});
const apiCreateSpy = vi.fn();
+const { listAttachableSkills, getHubSkill } = vi.hoisted(() => ({
+ listAttachableSkills: vi.fn(),
+ getHubSkill: vi.fn(),
+}));
vi.mock('../composables/useAgentApi', () => ({
createAgentSkill: (...args: unknown[]) => apiCreateSpy(...args),
+ listAttachableSkills,
+ getHubSkill,
}));
-const { showMessage, trackImportedSkill } = vi.hoisted(() => ({
+const { showMessage, showError, trackImportedSkill } = vi.hoisted(() => ({
showMessage: vi.fn(),
+ showError: vi.fn(),
trackImportedSkill: vi.fn(),
}));
vi.mock('@n8n/composables/useToast', () => ({
- useToast: () => ({ showMessage }),
+ useToast: () => ({ showMessage, showError }),
+}));
+
+vi.mock('@/features/shared/toolsConnection/ToolsConnectionModal.vue', () => ({
+ default: {
+ name: 'ToolsConnectionModal',
+ props: ['items', 'emptyMessage', 'createAction', 'categories', 'categoryLabels'],
+ emits: ['connect', 'disconnect', 'open-detail', 'create'],
+ template: `
+
+
{{ categories.join(',') }} {{ categoryLabels?.project }}
+
+ {{ createAction.label }}
+
+
+
{{ emptyMessage }}
+
+ {{ item.title }}
+ {{ item.category }}
+ {{ item.disabledReason }}
+
+
+
+ `,
+ },
}));
vi.mock('../composables/useAgentTelemetry', () => ({
@@ -46,12 +85,23 @@ function renderModal({
skill,
skillId,
existingSkillNames,
+ ...hubOptions
}: {
- onConfirm?: (payload: { id?: string; skill: AgentSkill }) => void;
+ onConfirm?: AgentSkillModalData['onConfirm'];
skill?: AgentSkill;
skillId?: string;
existingSkillNames?: string[];
-} = {}) {
+} & Pick<
+ AgentSkillModalData,
+ | 'hubSkillsEnabled'
+ | 'getAttachedSkills'
+ | 'projectLabel'
+ | 'canCreateInstanceSkills'
+ | 'onAttach'
+ | 'onRemove'
+ | 'outdated'
+ | 'onUseLatestVersion'
+> = {}) {
const renderComponent = createComponentRenderer(AgentSkillModal, {
global: {
stubs: {
@@ -82,6 +132,7 @@ function renderModal({
skillId,
existingSkillNames,
onConfirm,
+ ...hubOptions,
},
},
});
@@ -193,7 +244,7 @@ describe('AgentSkillModal', () => {
existingSkillNames: ['Other skill'],
});
- await fireEvent.update(getTitleInput(container), ' other SKILL ');
+ await fireEvent.update(getTitleInput(container), ' other_SKILL ');
expect(queryByTestId('agent-modal-title-error')).not.toBeInTheDocument();
await fireEvent.click(getByTestId('agent-skill-create-save'));
@@ -380,8 +431,597 @@ describe('AgentSkillModal', () => {
});
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME);
});
+
+ it('creates a skill on the agent without the picker or the skills API when the skills hub is off', async () => {
+ const onConfirm = vi.fn();
+ const { container, getByTestId, queryByTestId } = renderModal({
+ hubSkillsEnabled: false,
+ onAttach: vi.fn(),
+ onConfirm,
+ });
+
+ expect(queryByTestId('agent-skill-picker')).not.toBeInTheDocument();
+ expect(getByTestId('agent-skill-upload')).toBeInTheDocument();
+ await fireEvent.click(getByTestId('agent-skill-add-manually'));
+ expect(queryByTestId('agent-skill-availability-select')).not.toBeInTheDocument();
+ await fireEvent.update(getTitleInput(container), 'Research');
+ await fireEvent.update(
+ getByTestId('agent-skill-description-input').querySelector('input')!,
+ 'Use for research',
+ );
+ await fireEvent.update(getByTestId('agent-skill-instructions-editor'), 'Read the guide');
+ await fireEvent.click(getByTestId('agent-skill-create-save'));
+
+ expect(onConfirm).toHaveBeenCalledWith(
+ expect.not.objectContaining({ availableTo: expect.anything() }),
+ );
+ expect(listAttachableSkills).not.toHaveBeenCalled();
+ expect(getHubSkill).not.toHaveBeenCalled();
+ });
+
+ describe('with the skills hub', () => {
+ const projectSkill = makeListItem({ id: 'tone', name: 'Support tone', scope: 'project' });
+ const instanceSkill = makeListItem({ id: 'sql', name: 'SQL helper', scope: 'instance' });
+ const similarSkill = makeListItem({ id: 'voice', name: 'brand-voice', scope: 'instance' });
+ const agentSkill = makeListItem({
+ id: 'notes',
+ name: 'Call notes',
+ scope: 'project',
+ agentId: 'a1',
+ });
+
+ it('lists attachable skills in project, agent and global tabs and attaches one without closing', async () => {
+ listAttachableSkills.mockResolvedValue([projectSkill, agentSkill, instanceSkill]);
+ const onAttach = vi.fn().mockResolvedValue(undefined);
+ const { findAllByTestId, getAllByTestId, getByTestId, queryByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ projectLabel: 'Marketing',
+ onAttach,
+ });
+
+ expect(listAttachableSkills).toHaveBeenCalledWith(expect.anything(), 'a1');
+ expect(await findAllByTestId('agent-skill-picker-row')).toHaveLength(3);
+ const picker = getByTestId('agent-skill-picker');
+ expect(getAllByTestId('agent-skill-picker-category').map((node) => node.textContent)).toEqual(
+ ['project', 'this-agent', 'global'],
+ );
+ expect(queryByTestId('agent-skill-upload')).not.toBeInTheDocument();
+ expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
+
+ await fireEvent.click(getAllByTestId('agent-skill-picker-toggle')[2]);
+
+ expect(onAttach).toHaveBeenCalledWith(instanceSkill);
+ expect(uiStore.closeModal).not.toHaveBeenCalled();
+ expect(picker).toBeVisible();
+ });
+
+ it('names the project tab after the project of the agent', async () => {
+ listAttachableSkills.mockResolvedValue([]);
+ const { getByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ projectLabel: 'Marketing',
+ onAttach: vi.fn(),
+ });
+
+ expect(getByTestId('agent-skill-picker-tabs')).toHaveTextContent(
+ 'all,project,this-agent,global Marketing',
+ );
+ });
+
+ it('shows a skill as attached while it attaches', async () => {
+ listAttachableSkills.mockResolvedValue([instanceSkill]);
+ let finishAttach = () => {};
+ const onAttach = vi.fn(
+ async () => await new Promise((resolve) => (finishAttach = resolve)),
+ );
+ const { findByTestId } = renderModal({ hubSkillsEnabled: true, onAttach });
+
+ const toggle = await findByTestId('agent-skill-picker-toggle');
+ await fireEvent.click(toggle);
+
+ expect(await findByTestId('agent-skill-picker-toggle')).toHaveAttribute(
+ 'data-attached',
+ 'true',
+ );
+ finishAttach();
+ await waitFor(async () =>
+ expect(await findByTestId('agent-skill-picker-toggle')).toHaveAttribute(
+ 'data-attached',
+ 'false',
+ ),
+ );
+ });
+
+ it('detaches an attached skill without closing and refuses names that are hard to tell apart', async () => {
+ listAttachableSkills.mockResolvedValue([projectSkill, similarSkill]);
+ const onAttach = vi.fn();
+ const onRemove = vi.fn();
+ const { findAllByTestId, getByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ getAttachedSkills: () => [
+ { id: 'tone', name: 'Support tone' },
+ { id: 'other', name: 'Brand voice' },
+ ],
+ onAttach,
+ onRemove,
+ });
+
+ expect(await findAllByTestId('agent-skill-picker-row')).toHaveLength(2);
+ expect(getByTestId('agent-skill-picker-disabled')).toHaveTextContent(
+ 'agents.builder.skills.picker.similarName',
+ );
+ const toggle = getByTestId('agent-skill-picker-toggle');
+ expect(toggle).toHaveAttribute('data-attached', 'true');
+
+ await fireEvent.click(toggle);
+
+ expect(onRemove).toHaveBeenCalledWith('tone');
+ expect(onAttach).not.toHaveBeenCalled();
+ expect(uiStore.closeModal).not.toHaveBeenCalled();
+ });
+
+ it('shows an empty message when no skill can be attached', async () => {
+ listAttachableSkills.mockResolvedValue([]);
+ const { findByTestId } = renderModal({ hubSkillsEnabled: true, onAttach: vi.fn() });
+
+ await waitFor(async () =>
+ expect(await findByTestId('agent-skill-picker-empty')).toHaveTextContent(
+ 'agents.builder.skills.picker.empty',
+ ),
+ );
+ });
+
+ it('goes from the picker to the upload step and back', async () => {
+ listAttachableSkills.mockResolvedValue([projectSkill]);
+ const { getByTestId, queryByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ onAttach: vi.fn(),
+ });
+
+ await fireEvent.click(getByTestId('agent-skill-picker-create'));
+ expect(getByTestId('agent-skill-upload')).toBeInTheDocument();
+
+ await fireEvent.click(getByTestId('agent-modal-back'));
+ expect(queryByTestId('agent-skill-upload')).not.toBeInTheDocument();
+ expect(getByTestId('agent-skill-picker')).toBeVisible();
+ });
+
+ it('opens a read-only preview from a row click and adds the skill from there', async () => {
+ listAttachableSkills.mockResolvedValue([projectSkill, instanceSkill]);
+ getHubSkill.mockResolvedValue(
+ makeDetail({ id: 'sql', name: 'SQL helper', scope: 'instance', canEdit: true }),
+ );
+ const onAttach = vi.fn();
+ const { findAllByTestId, findByTestId, getByTestId, queryByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ onAttach,
+ });
+
+ await fireEvent.click((await findAllByTestId('agent-skill-picker-row-open'))[1]);
+
+ expect(await findByTestId('agent-skill-preview')).toBeInTheDocument();
+ expect(getHubSkill).toHaveBeenCalledWith(expect.anything(), 'sql');
+ expect(getByTestId('agent-skill-instructions-text')).toHaveTextContent('Instructions');
+ expect(queryByTestId('agent-skill-instructions-editor')).not.toBeInTheDocument();
+ expect(getByTestId('agent-skill-footer-hint')).toHaveTextContent(
+ 'agents.builder.skills.hint.addToEdit',
+ );
+ expect(queryByTestId('agent-modal-title-input')).not.toBeInTheDocument();
+ expect(queryByTestId('agent-skill-add-reference')).not.toBeInTheDocument();
+ expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
+ expect(queryByTestId('agent-skill-remove')).not.toBeInTheDocument();
+ expect(onAttach).not.toHaveBeenCalled();
+
+ await fireEvent.click(getByTestId('agent-skill-preview-add'));
+
+ expect(onAttach).toHaveBeenCalledWith(instanceSkill);
+ expect(uiStore.closeModal).not.toHaveBeenCalled();
+ expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
+ expect(getByTestId('agent-skill-picker')).toBeVisible();
+ });
+
+ it('offers to remove an attached skill from its preview', async () => {
+ listAttachableSkills.mockResolvedValue([projectSkill]);
+ getHubSkill.mockResolvedValue(
+ makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
+ );
+ const onRemove = vi.fn();
+ const { findByTestId, getByTestId, queryByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ getAttachedSkills: () => [{ id: 'tone', name: 'Support tone' }],
+ onAttach: vi.fn(),
+ onRemove,
+ });
+
+ await fireEvent.click(await findByTestId('agent-skill-picker-row-open'));
+ await findByTestId('agent-skill-preview');
+
+ expect(queryByTestId('agent-skill-preview-add')).not.toBeInTheDocument();
+ expect(queryByTestId('agent-skill-footer-hint')).not.toBeInTheDocument();
+ await fireEvent.click(getByTestId('agent-skill-remove'));
+
+ expect(onRemove).toHaveBeenCalledWith('tone');
+ expect(uiStore.closeModal).not.toHaveBeenCalled();
+ expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
+ expect(getByTestId('agent-skill-picker')).toBeVisible();
+ });
+
+ it('returns from the preview to the picker', async () => {
+ listAttachableSkills.mockResolvedValue([projectSkill]);
+ getHubSkill.mockResolvedValue(
+ makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
+ );
+ const { findByTestId, getByTestId, queryByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ onAttach: vi.fn(),
+ });
+
+ await fireEvent.click(await findByTestId('agent-skill-picker-row-open'));
+ await findByTestId('agent-skill-preview');
+ await fireEvent.click(getByTestId('agent-modal-back'));
+
+ expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
+ expect(getByTestId('agent-skill-picker')).toBeVisible();
+ });
+
+ it('stays on the picker when the preview cannot load', async () => {
+ listAttachableSkills.mockResolvedValue([projectSkill]);
+ getHubSkill.mockRejectedValue(new Error('Not found'));
+ const { findByTestId, getByTestId, queryByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ onAttach: vi.fn(),
+ });
+
+ await fireEvent.click(await findByTestId('agent-skill-picker-row-open'));
+
+ await waitFor(() =>
+ expect(showError).toHaveBeenCalledWith(
+ expect.any(Error),
+ 'agents.builder.skills.picker.previewError',
+ ),
+ );
+ expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
+ expect(getByTestId('agent-skill-picker')).toBeVisible();
+ });
+
+ it.each([
+ ['project', 'agents.builder.skills.availableTo.project'],
+ ['instance', 'agents.builder.skills.availableTo.instance'],
+ ] as const)(
+ 'saves a new skill for the %s from the availability select',
+ async (availableTo, label) => {
+ listAttachableSkills.mockResolvedValue([]);
+ const onConfirm = vi.fn();
+ const { container, getByTestId, findByText } = renderModal({
+ hubSkillsEnabled: true,
+ canCreateInstanceSkills: true,
+ projectLabel: 'Marketing',
+ onAttach: vi.fn(),
+ onConfirm,
+ });
+
+ await fireEvent.click(getByTestId('agent-skill-picker-create'));
+ await fireEvent.click(getByTestId('agent-skill-add-manually'));
+ await fireEvent.update(getTitleInput(container), 'Research');
+ await fireEvent.update(
+ getByTestId('agent-skill-description-input').querySelector('input')!,
+ 'Use for research',
+ );
+ await fireEvent.update(getByTestId('agent-skill-instructions-editor'), 'Read the guide');
+ await fireEvent.click(
+ getByTestId('agent-skill-availability-select').querySelector('.select-trigger')!,
+ );
+ await fireEvent.click(await findByText(label));
+ await fireEvent.click(getByTestId('agent-skill-create-save'));
+
+ expect(onConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({ id: undefined, availableTo }),
+ );
+ },
+ );
+
+ it.each([
+ ['all', true, 'agent'],
+ ['project', true, 'project'],
+ ['this-agent', true, 'agent'],
+ ['global', true, 'instance'],
+ ['global', false, 'agent'],
+ ] as const)(
+ 'starts a new skill from the %s tab with a matching target (global allowed: %s)',
+ async (category, canCreateInstanceSkills, availableTo) => {
+ listAttachableSkills.mockResolvedValue([]);
+ const onConfirm = vi.fn();
+ const { container, getByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ canCreateInstanceSkills,
+ onAttach: vi.fn(),
+ onConfirm,
+ });
+
+ await fireEvent.click(getByTestId(`agent-skill-picker-create-${category}`));
+ await fireEvent.click(getByTestId('agent-skill-add-manually'));
+ await fireEvent.update(getTitleInput(container), 'Research');
+ await fireEvent.update(
+ getByTestId('agent-skill-description-input').querySelector('input')!,
+ 'Use for research',
+ );
+ await fireEvent.update(getByTestId('agent-skill-instructions-editor'), 'Read the guide');
+ await fireEvent.click(getByTestId('agent-skill-create-save'));
+
+ expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ availableTo }));
+ },
+ );
+
+ it('checks a new skill name against the skills attached after the picker opened', async () => {
+ listAttachableSkills.mockResolvedValue([]);
+ const attached = ref>([]);
+ const onConfirm = vi.fn();
+ const { container, getByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ onAttach: vi.fn(),
+ getAttachedSkills: () => attached.value,
+ onConfirm,
+ });
+
+ attached.value = [{ id: 'brand', name: 'P-Brand voice' }];
+ await fireEvent.click(getByTestId('agent-skill-picker-create'));
+ await fireEvent.click(getByTestId('agent-skill-add-manually'));
+ await fireEvent.update(getTitleInput(container), 'p brand voice');
+ await fireEvent.update(
+ getByTestId('agent-skill-description-input').querySelector('input')!,
+ 'Use for replies',
+ );
+ await fireEvent.update(getByTestId('agent-skill-instructions-editor'), 'Be kind');
+ await fireEvent.click(getByTestId('agent-skill-create-save'));
+
+ expect(onConfirm).not.toHaveBeenCalled();
+ expect(getByTestId('agent-modal-title-error')).toHaveTextContent(
+ 'agents.builder.skills.validation.nameDuplicate',
+ );
+ });
+
+ it('offers only this project and this agent to a user who may not create global skills', async () => {
+ listAttachableSkills.mockResolvedValue([]);
+ const { getByTestId, findByText, queryByText } = renderModal({
+ hubSkillsEnabled: true,
+ onAttach: vi.fn(),
+ });
+
+ await fireEvent.click(getByTestId('agent-skill-picker-create'));
+ await fireEvent.click(getByTestId('agent-skill-add-manually'));
+ await fireEvent.click(
+ getByTestId('agent-skill-availability-select').querySelector('.select-trigger')!,
+ );
+
+ expect(await findByText('agents.builder.skills.availableTo.agent')).toBeInTheDocument();
+ expect(queryByText('agents.builder.skills.availableTo.instance')).not.toBeInTheDocument();
+ });
+
+ it('keeps a skill read-only and reports the error when its access check fails', async () => {
+ const error = new Error('offline');
+ getHubSkill.mockRejectedValue(error);
+ const { findByTestId, queryByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ skillId: 'sql',
+ skill: { name: 'SQL helper', description: 'Use for SQL', instructions: 'Write SQL' },
+ onRemove: vi.fn(),
+ });
+
+ await findByTestId('agent-skill-remove');
+ await vi.waitFor(() =>
+ expect(showError).toHaveBeenCalledWith(error, 'agents.builder.skills.accessError'),
+ );
+ expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
+ });
+
+ it('shows a skill that the user cannot edit as read-only', async () => {
+ getHubSkill.mockResolvedValue(
+ makeDetail({ id: 'sql', name: 'SQL helper', scope: 'instance', canEdit: false }),
+ );
+ const onRemove = vi.fn();
+ const { findByTestId, getByTestId, queryByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ skillId: 'sql',
+ skill: { name: 'SQL helper', description: 'Use for SQL', instructions: 'Write SQL' },
+ onRemove,
+ });
+
+ expect(await findByTestId('agent-skill-footer-hint')).toHaveTextContent(
+ 'agents.builder.skills.hint.readOnly.instance',
+ );
+ expect(getByTestId('agent-skill-description-text')).toHaveTextContent('Use for SQL');
+ expect(queryByTestId('agent-skill-description-input')).not.toBeInTheDocument();
+ expect(getHubSkill).toHaveBeenCalledWith(expect.anything(), 'sql');
+ expect(queryByTestId('agent-modal-title-input')).not.toBeInTheDocument();
+ expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
+ expect(queryByTestId('agent-skill-add-reference')).not.toBeInTheDocument();
+
+ await fireEvent.click(getByTestId('agent-skill-remove'));
+ expect(onRemove).toHaveBeenCalledWith('sql');
+ });
+
+ describe('where an existing skill can be added', () => {
+ const tone = {
+ name: 'Support tone',
+ description: 'Use for replies',
+ instructions: 'Be kind',
+ };
+
+ async function renderEdit(detail: SkillDetail, onConfirm = vi.fn()) {
+ getHubSkill.mockResolvedValue(detail);
+ const result = renderModal({
+ hubSkillsEnabled: true,
+ skillId: detail.id,
+ skill: tone,
+ onConfirm,
+ onRemove: vi.fn(),
+ });
+ await result.findByTestId('agent-skill-availability-select');
+ return result;
+ }
+
+ it('enables save only after the skill or its target changes', async () => {
+ const { getByTestId } = await renderEdit(
+ makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
+ );
+
+ expect(getByTestId('agent-skill-create-save')).toBeDisabled();
+
+ await fireEvent.update(
+ getByTestId('agent-skill-description-input').querySelector('input')!,
+ 'Use for all replies',
+ );
+
+ expect(getByTestId('agent-skill-create-save')).toBeEnabled();
+ });
+
+ it('changes the target of a skill that no other agent uses', async () => {
+ const onConfirm = vi.fn();
+ const { getByTestId, findByText } = await renderEdit(
+ {
+ ...makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
+ usedBy: {
+ drafts: [{ agentId: 'a1', agentName: 'This agent', projectId: 'p1' }],
+ pins: [{ agentId: 'a1', agentName: 'This agent', projectId: 'p1', version: 1 }],
+ hiddenAgents: 0,
+ },
+ },
+ onConfirm,
+ );
+
+ const select = getByTestId('agent-skill-availability-select');
+ await fireEvent.click(select.querySelector('.select-trigger')!);
+ await fireEvent.click(await findByText('agents.builder.skills.availableTo.agent'));
+ await fireEvent.click(getByTestId('agent-skill-create-save'));
+
+ expect(onConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'tone', availableTo: 'agent' }),
+ );
+ });
+
+ it('does not send a target when the user keeps it', async () => {
+ const onConfirm = vi.fn();
+ const { getByTestId } = await renderEdit(
+ makeDetail({ id: 'tone', name: 'Support tone', scope: 'project', agentId: 'a1' }),
+ onConfirm,
+ );
+
+ await fireEvent.click(getByTestId('agent-skill-create-save'));
+
+ expect(onConfirm).toHaveBeenCalledWith(
+ expect.not.objectContaining({ availableTo: expect.anything() }),
+ );
+ });
+
+ it.each([
+ [
+ 'a draft of another agent',
+ {
+ drafts: [{ agentId: 'a2', agentName: 'Other', projectId: 'p1' }],
+ pins: [],
+ hiddenAgents: 0,
+ },
+ ],
+ [
+ 'a pin of another agent',
+ {
+ drafts: [],
+ pins: [{ agentId: 'a2', agentName: 'Other', projectId: 'p1', version: null }],
+ hiddenAgents: 0,
+ },
+ ],
+ ['an agent the user cannot see', { drafts: [], pins: [], hiddenAgents: 1 }],
+ ])('locks the target when %s uses the skill', async (_case, usedBy) => {
+ const { getByTestId } = await renderEdit({
+ ...makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
+ usedBy,
+ });
+
+ expect(
+ getByTestId('agent-skill-availability-select').querySelector('.is-disabled, [disabled]'),
+ ).not.toBeNull();
+ });
+ });
+
+ it('shows a skill on an older version as read-only with a switch to the latest version', async () => {
+ getHubSkill.mockResolvedValue(
+ makeDetail({ id: 'tone', name: 'Support tone', scope: 'project', canEdit: true }),
+ );
+ const onUseLatestVersion = vi.fn();
+ const { findByTestId, getByTestId, queryByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ skillId: 'tone',
+ skill: { name: 'Support tone', description: 'Use for replies', instructions: 'Be kind' },
+ outdated: true,
+ onUseLatestVersion,
+ onRemove: vi.fn(),
+ });
+
+ expect(await findByTestId('agent-skill-footer-hint')).toHaveTextContent(
+ 'agents.builder.skills.hint.outdated',
+ );
+ expect(getByTestId('agent-skill-description-text')).toHaveTextContent('Use for replies');
+ expect(queryByTestId('agent-modal-title-input')).not.toBeInTheDocument();
+ expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
+ expect(getByTestId('agent-skill-remove')).toBeInTheDocument();
+
+ await fireEvent.click(getByTestId('agent-skill-use-latest'));
+
+ expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME);
+ expect(onUseLatestVersion).toHaveBeenCalledWith('tone');
+ });
+
+ it('keeps a skill that the user can edit editable', async () => {
+ getHubSkill.mockResolvedValue(
+ makeDetail({ id: 'tone', name: 'Support tone', scope: 'project', canEdit: true }),
+ );
+ const { findByTestId, queryByTestId } = renderModal({
+ hubSkillsEnabled: true,
+ skillId: 'tone',
+ skill: { name: 'Support tone', description: 'Use for replies', instructions: 'Be kind' },
+ });
+
+ await waitFor(() => expect(getHubSkill).toHaveBeenCalled());
+ expect(await findByTestId('agent-modal-title-input')).toBeInTheDocument();
+ expect(queryByTestId('agent-skill-footer-hint')).not.toBeInTheDocument();
+ expect(queryByTestId('agent-skill-description-input')).toBeInTheDocument();
+ expect(queryByTestId('agent-skill-create-save')).toBeInTheDocument();
+ });
+ });
});
+function makeListItem(
+ overrides: Pick & Partial,
+): SkillListItem {
+ return {
+ description: `${overrides.name} description`,
+ projectId: overrides.scope === 'project' ? 'p1' : null,
+ projectName: overrides.scope === 'project' ? 'Marketing' : null,
+ agentId: null,
+ agentName: null,
+ source: 'ui',
+ latestVersion: 1,
+ usedByAgents: 0,
+ canEdit: true,
+ canDelete: true,
+ createdAt: '2026-10-01T00:00:00.000Z',
+ updatedAt: '2026-10-01T00:00:00.000Z',
+ ...overrides,
+ };
+}
+
+function makeDetail(
+ overrides: Pick & Partial,
+): SkillDetail {
+ const item = makeListItem(overrides);
+ return {
+ ...item,
+ skill: { name: item.name, description: item.description, instructions: 'Instructions' },
+ skillHash: 'hash',
+ usedBy: { drafts: [], pins: [], hiddenAgents: 0 },
+ };
+}
+
function getTitleInput(container: Element): HTMLInputElement {
const input = container.querySelector(
'[data-testid="agent-modal-title-input"]',
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts
index c489f9d0587..61942c174eb 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts
@@ -115,6 +115,61 @@ describe('AgentSkillViewer', () => {
expect(wrapper.emitted('select:path')).toBeUndefined();
});
+ it('shows a read-only skill as text, without form fields', () => {
+ const wrapper = mountViewer({
+ readOnly: true,
+ skill: {
+ name: 'Triage',
+ description: 'Use for triage',
+ instructions: 'Check the SLA first.',
+ references: [{ path: 'references/labels.md', content: 'Use needs-repro.' }],
+ },
+ });
+
+ expect(wrapper.find('[data-testid="agent-skill-description-text"]').text()).toBe(
+ 'Use for triage',
+ );
+ expect(wrapper.find('[data-testid="agent-skill-instructions-text"]').text()).toContain(
+ 'Check the SLA first.',
+ );
+ expect(wrapper.find('[data-testid="agent-skill-description-input"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="agent-skill-instructions-editor"]').exists()).toBe(false);
+ expect(wrapper.text()).not.toContain('characters');
+ });
+
+ it('shows the task lists of a read-only skill as disabled checkboxes', () => {
+ const wrapper = mountViewer({
+ readOnly: true,
+ skill: {
+ name: 'Triage',
+ description: 'Use for triage',
+ instructions: '- [ ] Check the SLA',
+ },
+ });
+
+ const checkbox = wrapper.find('[data-testid="agent-skill-instructions-text"] input');
+ expect(checkbox.attributes('type')).toBe('checkbox');
+ expect(checkbox.attributes('disabled')).toBeDefined();
+ });
+
+ it('shows a read-only reference as text', () => {
+ const wrapper = mountViewer({
+ readOnly: true,
+ selectedPath: 'references/labels.md',
+ skill: {
+ name: 'Triage',
+ description: 'Use for triage',
+ instructions: 'Check the SLA first.',
+ references: [{ path: 'references/labels.md', content: 'Use needs-repro.' }],
+ },
+ });
+
+ expect(wrapper.find('[data-testid="agent-skill-reference-text"]').text()).toContain(
+ 'Use needs-repro.',
+ );
+ expect(wrapper.find('[data-testid="agent-skill-reference-name-input"]').exists()).toBe(false);
+ });
+
it('shows the instructions character count, not their byte size', () => {
const wrapper = mountViewer({
skill: {
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts
index 6837afd28ed..647a9f753d6 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts
@@ -10,9 +10,6 @@ vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
}));
-const showMessage = vi.fn();
-vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showMessage }) }));
-
const skill = { name: 'Skill', description: 'A skill', instructions: 'Do it.' };
describe('AgentSkillsSection', () => {
@@ -35,7 +32,7 @@ describe('AgentSkillsSection', () => {
expect(current.find('[data-testid="agent-chip-warning-icon"]').exists()).toBe(false);
});
- it('does not open an outdated skill and tells the user to add it again', async () => {
+ it('opens an outdated skill like any other skill', async () => {
const wrapper = mount(AgentSkillsSection, {
props: {
skills: [
@@ -50,11 +47,6 @@ describe('AgentSkillsSection', () => {
await outdated.trigger('click');
await current.trigger('click');
- expect(wrapper.emitted('open-skill')).toEqual([['current']]);
- expect(showMessage).toHaveBeenCalledWith({
- type: 'warning',
- title: 'agents.builder.skills.outdated.title',
- message: 'agents.builder.skills.outdated.warning',
- });
+ expect(wrapper.emitted('open-skill')).toEqual([['outdated'], ['current']]);
});
});
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts
index 8c0304f202a..cbd2d40c369 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts
@@ -23,7 +23,12 @@ import {
listN8nChatAgents,
listN8nChatThreads,
duplicateAgent,
+ listAttachableSkills,
+ createAgentSkill,
+ getHubSkill,
+ updateAgentSkill,
} from '../composables/useAgentApi';
+import type { SkillListItem } from '@n8n/api-types';
import type { AgentResource, AgentJsonConfig } from '../types';
vi.mock('@n8n/rest-api-client', () => ({
@@ -39,6 +44,79 @@ describe('useAgentApi', () => {
vi.clearAllMocks();
});
+ describe('listAttachableSkills', () => {
+ it('lists the attachable skills of the agent in one call, by target and then by name', async () => {
+ const skill = (name: string, scope: SkillListItem['scope'], agentId: string | null = null) =>
+ ({ id: name, name, scope, agentId }) as SkillListItem;
+ vi.mocked(makeRestApiRequest).mockResolvedValueOnce({
+ count: 5,
+ data: [
+ skill('Tone', 'instance'),
+ skill('Notes', 'project', 'agent-1'),
+ skill('SQL', 'project'),
+ skill('Brand', 'instance'),
+ skill('Billing', 'project'),
+ ],
+ });
+
+ const result = await listAttachableSkills(restApiContext, 'agent-1');
+
+ expect(makeRestApiRequest).toHaveBeenCalledWith(restApiContext, 'GET', '/skills', {
+ attachableTo: 'agent-1',
+ });
+ expect(result.map(({ name }) => name)).toEqual(['Billing', 'SQL', 'Notes', 'Brand', 'Tone']);
+ });
+ });
+
+ describe('hub skill requests', () => {
+ const skill = { name: 'Tone', description: 'Use for replies', instructions: 'Be kind' };
+
+ it('creates an agent skill with its target, and without one when none is given', async () => {
+ await createAgentSkill(restApiContext, 'project-1', 'agent-1', skill, 'agent');
+ await createAgentSkill(restApiContext, 'project-1', 'agent-1', skill);
+
+ expect(makeRestApiRequest).toHaveBeenNthCalledWith(
+ 1,
+ restApiContext,
+ 'POST',
+ '/projects/project-1/agents/v2/agent-1/skills',
+ { ...skill, availableTo: 'agent' },
+ );
+ expect(makeRestApiRequest).toHaveBeenNthCalledWith(
+ 2,
+ restApiContext,
+ 'POST',
+ '/projects/project-1/agents/v2/agent-1/skills',
+ skill,
+ );
+ });
+
+ it('saves the skill text and its target in one request', async () => {
+ await updateAgentSkill(
+ restApiContext,
+ 'project-1',
+ 'agent-1',
+ 'skill-1',
+ skill,
+ 'hash-1',
+ 'instance',
+ );
+
+ expect(makeRestApiRequest).toHaveBeenCalledWith(
+ restApiContext,
+ 'PATCH',
+ '/projects/project-1/agents/v2/agent-1/skills/skill-1',
+ { ...skill, baseSkillHash: 'hash-1', availableTo: 'instance' },
+ );
+ });
+
+ it('gets one hub skill', async () => {
+ await getHubSkill(restApiContext, 'skill-1');
+
+ expect(makeRestApiRequest).toHaveBeenCalledWith(restApiContext, 'GET', '/skills/skill-1');
+ });
+ });
+
describe('listAgentsPage', () => {
it('requests agents using the raw paginated response contract', async () => {
const response = {
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts
index 395be09cd05..60f3f3d297e 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts
@@ -5,9 +5,11 @@ import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { useUIStore } from '@/app/stores/ui.store';
+import { useSettingsStore } from '@n8n/stores/settings.store';
import { AGENT_TOOLS_MODAL_KEY, AGENT_SKILL_MODAL_KEY } from '../constants';
import { useAgentCapabilitiesActions } from '../composables/useAgentCapabilitiesActions';
-import { createAgentSkill } from '../composables/useAgentApi';
+import { createAgentSkill, getHubSkill } from '../composables/useAgentApi';
+import type { SkillDetail, SkillListItem } from '@n8n/api-types';
import type {
AgentJsonConfig,
AgentJsonMcpServerConfig,
@@ -34,6 +36,14 @@ vi.mock('@n8n/composables/useToast', () => ({
vi.mock('../composables/useAgentApi', () => ({
createAgentSkill: vi.fn(),
+ getHubSkill: vi.fn(),
+}));
+
+const { hasScopeSpy } = vi.hoisted(() => ({
+ hasScopeSpy: vi.fn(() => false),
+}));
+vi.mock('@n8n/stores/rbac.store', () => ({
+ useRBACStore: () => ({ hasScope: hasScopeSpy }),
}));
function makeConfig(overrides: Partial = {}): AgentJsonConfig {
@@ -51,6 +61,7 @@ function setup(
overrides: {
supportsToolApproval?: boolean;
ensureAgentPersisted?: () => Promise;
+ flushConfigSave?: () => Promise;
beforeAgentMutation?: () => Promise;
refreshAgentAfterMutation?: (projectId: string, agentId: string) => Promise;
agent?: Ref;
@@ -63,6 +74,7 @@ function setup(
const localConfig = ref(makeConfig());
const scheduleConfigUpdate = vi.fn();
+ const scheduleSkillSave = vi.fn();
const agentId = overrides.agentId ?? ref('inline:node-1');
const projectId = overrides.projectId ?? ref('project-1');
@@ -73,11 +85,19 @@ function setup(
agentId: computed(() => agentId.value),
connectedTriggers: ref([]),
scheduleConfigUpdate,
- scheduleSkillSave: vi.fn(),
+ scheduleSkillSave,
...overrides,
});
- return { uiStore, actions, scheduleConfigUpdate, localConfig, agentId, projectId };
+ return {
+ uiStore,
+ actions,
+ scheduleConfigUpdate,
+ scheduleSkillSave,
+ localConfig,
+ agentId,
+ projectId,
+ };
}
type ToolsModalData = {
@@ -150,8 +170,19 @@ describe('useAgentCapabilitiesActions — tools modal host seam', () => {
type SkillModalData = {
skillId?: string;
+ skill?: AgentSkill;
existingSkillNames?: string[];
- onConfirm: (payload: { id?: string; skill: AgentSkill }) => void;
+ hubSkillsEnabled?: boolean;
+ outdated?: boolean;
+ onUseLatestVersion?: (skillId: string) => void;
+ getAttachedSkills?: () => Array<{ id: string; name: string }>;
+ canCreateInstanceSkills?: boolean;
+ onConfirm: (payload: {
+ id?: string;
+ skill: AgentSkill;
+ availableTo?: 'project' | 'agent' | 'instance';
+ }) => void;
+ onAttach?: (skill: SkillListItem) => Promise;
onRemove?: (skillId: string) => void;
};
@@ -580,3 +611,378 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => {
expect(actions.appliedSkills.value).toEqual([]);
});
});
+
+describe('useAgentCapabilitiesActions — skills hub', () => {
+ const supportTone: AgentSkill = {
+ name: 'Support tone',
+ description: 'Use for support replies',
+ instructions: 'Be kind.',
+ };
+ const sqlHelper: AgentSkill = {
+ name: 'SQL helper',
+ description: 'Use for SQL questions',
+ instructions: 'Write SQL.',
+ };
+
+ function makeDetail(id: string, skill: AgentSkill): SkillDetail {
+ return {
+ id,
+ name: skill.name,
+ description: skill.description,
+ scope: 'instance',
+ projectId: null,
+ projectName: null,
+ agentId: null,
+ agentName: null,
+ source: 'ui',
+ latestVersion: 1,
+ usedByAgents: 0,
+ canEdit: false,
+ canDelete: false,
+ createdAt: '2026-10-01T00:00:00.000Z',
+ updatedAt: '2026-10-01T00:00:00.000Z',
+ skill,
+ skillHash: `hash-${id}`,
+ usedBy: { drafts: [], pins: [], hiddenAgents: 0 },
+ };
+ }
+
+ function setupHub({
+ skillsHubEnabled = true,
+ flushConfigSave,
+ }: { skillsHubEnabled?: boolean; flushConfigSave?: () => Promise } = {}) {
+ const agent = ref({
+ id: 'agent-1',
+ skills: { tone: supportTone },
+ skillHashes: { tone: 'hash-tone' },
+ } as unknown as AgentResource);
+ const result = setup({ agent, agentId: ref('agent-1'), flushConfigSave });
+ useSettingsStore().moduleSettings.agents = {
+ enabled: true,
+ modules: [],
+ knowledgeBaseEnabled: false,
+ proxyEnabled: false,
+ skillsHubEnabled,
+ };
+ result.localConfig.value = makeConfig({ skills: [{ type: 'skill', id: 'tone' }] });
+ result.actions.onOpenAddSkillModal();
+ const modalData = result.uiStore.modalsById[AGENT_SKILL_MODAL_KEY]
+ .data as unknown as SkillModalData;
+ return { ...result, agent, modalData };
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ hasScopeSpy.mockReturnValue(false);
+ });
+
+ it('saves a new agent before it opens the picker', async () => {
+ let releasePersist: () => void = () => {};
+ const persisted = new Promise((resolve) => {
+ releasePersist = resolve;
+ });
+ const { uiStore, actions } = setup({ ensureAgentPersisted: async () => await persisted });
+ useSettingsStore().moduleSettings.agents = {
+ enabled: true,
+ modules: [],
+ knowledgeBaseEnabled: false,
+ proxyEnabled: false,
+ skillsHubEnabled: true,
+ };
+
+ void actions.onOpenAddSkillModal();
+ await flushPromises();
+ expect(uiStore.modalsById[AGENT_SKILL_MODAL_KEY]?.open).toBeFalsy();
+
+ releasePersist();
+ await flushPromises();
+ expect(uiStore.modalsById[AGENT_SKILL_MODAL_KEY].open).toBe(true);
+ });
+
+ it('reports a failed agent save and keeps the picker closed', async () => {
+ const error = new Error('create failed');
+ const { uiStore, actions } = setup({
+ ensureAgentPersisted: async () => await Promise.reject(error),
+ });
+ useSettingsStore().moduleSettings.agents = {
+ enabled: true,
+ modules: [],
+ knowledgeBaseEnabled: false,
+ proxyEnabled: false,
+ skillsHubEnabled: true,
+ };
+
+ await actions.onOpenAddSkillModal();
+
+ expect(uiStore.modalsById[AGENT_SKILL_MODAL_KEY]?.open).toBeFalsy();
+ expect(showErrorSpy).toHaveBeenCalledWith(error, 'agents.builder.skills.picker.loadError');
+ });
+
+ it('opens the add modal with the picker data', () => {
+ hasScopeSpy.mockReturnValue(true);
+ const { modalData } = setupHub();
+
+ expect(modalData.hubSkillsEnabled).toBe(true);
+ expect(modalData.getAttachedSkills?.()).toEqual([{ id: 'tone', name: supportTone.name }]);
+ expect(modalData.canCreateInstanceSkills).toBe(true);
+ expect(hasScopeSpy).toHaveBeenCalledWith('skill:create');
+ });
+
+ it('keeps the picker off and creates the skill on the agent when the skills hub is off', async () => {
+ vi.mocked(createAgentSkill).mockResolvedValue({
+ id: 'sql',
+ skill: sqlHelper,
+ skillHash: 'hash-sql',
+ versionId: 'v2',
+ } as Awaited>);
+ const { modalData, actions, uiStore } = setupHub({ skillsHubEnabled: false });
+
+ expect(modalData.hubSkillsEnabled).toBe(false);
+ modalData.onConfirm({ skill: sqlHelper, availableTo: 'project' });
+ await flushPromises();
+ expect(createAgentSkill).toHaveBeenCalledWith(
+ expect.anything(),
+ 'project-1',
+ 'agent-1',
+ sqlHelper,
+ undefined,
+ );
+ expect(getHubSkill).not.toHaveBeenCalled();
+
+ actions.onOpenSkillFromList('tone');
+ const editData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
+ expect(editData.hubSkillsEnabled).toBe(false);
+ expect(editData.outdated).toBe(false);
+ });
+
+ it('attaches a hub skill as a ref through the config save, without a copy', async () => {
+ vi.mocked(getHubSkill).mockResolvedValue(makeDetail('sql', sqlHelper));
+ const { modalData, scheduleConfigUpdate, agent } = setupHub();
+
+ await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
+ await flushPromises();
+
+ expect(getHubSkill).toHaveBeenCalledWith(expect.anything(), 'sql');
+ expect(scheduleConfigUpdate).toHaveBeenCalledWith({
+ skills: [
+ { type: 'skill', id: 'tone' },
+ { type: 'skill', id: 'sql' },
+ ],
+ });
+ expect(agent.value?.skills.sql).toEqual(sqlHelper);
+ expect(agent.value?.skillHashes?.sql).toBe('hash-sql');
+ expect(createAgentSkill).not.toHaveBeenCalled();
+ });
+
+ it('undoes an attach when the config save fails', async () => {
+ vi.mocked(getHubSkill).mockResolvedValue(makeDetail('sql', sqlHelper));
+ const { modalData, scheduleConfigUpdate, agent } = setupHub({
+ flushConfigSave: async () => await Promise.reject(new Error('refused')),
+ });
+
+ await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
+ await flushPromises();
+
+ expect(scheduleConfigUpdate).toHaveBeenLastCalledWith({
+ skills: [{ type: 'skill', id: 'tone' }],
+ });
+ expect(agent.value?.skills.sql).toBeUndefined();
+ expect(agent.value?.skillHashes?.sql).toBeUndefined();
+ expect(agent.value?.skills.tone).toEqual(supportTone);
+ });
+
+ it('keeps an attach when the config save succeeds', async () => {
+ vi.mocked(getHubSkill).mockResolvedValue(makeDetail('sql', sqlHelper));
+ const { modalData, scheduleConfigUpdate, agent } = setupHub({
+ flushConfigSave: async () => await Promise.resolve(undefined),
+ });
+
+ await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
+ await flushPromises();
+
+ expect(scheduleConfigUpdate).toHaveBeenCalledTimes(1);
+ expect(agent.value?.skills.sql).toEqual(sqlHelper);
+ });
+
+ it('refuses to attach a skill whose name is hard to tell apart from an attached one', async () => {
+ vi.mocked(getHubSkill).mockResolvedValue(
+ makeDetail('tone-2', { ...supportTone, name: 'support_tone' }),
+ );
+ const { modalData, scheduleConfigUpdate } = setupHub();
+
+ await modalData.onAttach?.({ id: 'tone-2', name: 'support_tone' } as SkillListItem);
+ await flushPromises();
+
+ expect(scheduleConfigUpdate).not.toHaveBeenCalled();
+ expect(showMessageSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'agents.builder.skills.duplicateName.error' }),
+ );
+ });
+
+ it('reports an attach failure and leaves the config alone', async () => {
+ vi.mocked(getHubSkill).mockRejectedValue(new Error('Not found'));
+ const { modalData, scheduleConfigUpdate } = setupHub();
+
+ await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
+ await flushPromises();
+
+ expect(showErrorSpy).toHaveBeenCalledWith(
+ expect.any(Error),
+ 'agents.builder.skills.attach.error',
+ );
+ expect(scheduleConfigUpdate).not.toHaveBeenCalled();
+ });
+
+ it.each(['project', 'agent', 'instance'] as const)(
+ 'creates a skill for the %s through the agent endpoint',
+ async (availableTo) => {
+ vi.mocked(createAgentSkill).mockResolvedValue({
+ id: 'sql',
+ skill: sqlHelper,
+ skillHash: 'hash-sql',
+ versionId: 'v2',
+ });
+ const { modalData } = setupHub();
+
+ modalData.onConfirm({ skill: sqlHelper, availableTo });
+ await flushPromises();
+
+ expect(createAgentSkill).toHaveBeenCalledWith(
+ expect.anything(),
+ 'project-1',
+ 'agent-1',
+ sqlHelper,
+ availableTo,
+ );
+ },
+ );
+
+ it('detaches a skill by dropping its ref only', () => {
+ const { actions, scheduleConfigUpdate } = setupHub();
+
+ actions.onRemoveSkill('tone');
+
+ expect(scheduleConfigUpdate).toHaveBeenCalledWith({ skills: [] });
+ });
+
+ it('detaches a skill from the picker preview by dropping its ref only', () => {
+ const { modalData, scheduleConfigUpdate } = setupHub();
+
+ modalData.onRemove?.('tone');
+
+ expect(scheduleConfigUpdate).toHaveBeenCalledWith({ skills: [] });
+ });
+
+ it('marks a skill on an older version when the edit modal opens', () => {
+ const { actions, uiStore, agent } = setupHub();
+ agent.value = { ...agent.value, outdatedSkillIds: ['tone'] } as unknown as AgentResource;
+
+ actions.onOpenSkillFromList('tone');
+
+ const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
+ expect(modalData.outdated).toBe(true);
+ });
+
+ it('unpins the skill and saves before it opens the latest version in the editor', async () => {
+ const latest: AgentSkill = { ...supportTone, instructions: 'Be kind and brief.' };
+ vi.mocked(getHubSkill).mockResolvedValue({
+ ...makeDetail('tone', latest),
+ skillHash: 'hash-latest',
+ });
+ const agent = ref({
+ id: 'agent-1',
+ skills: { tone: supportTone },
+ skillHashes: { tone: 'hash-tone' },
+ outdatedSkillIds: ['tone'],
+ } as unknown as AgentResource);
+ const events: string[] = [];
+ const beforeAgentMutation = vi.fn(async () => {
+ events.push('flush');
+ });
+ const { actions, uiStore, localConfig, scheduleConfigUpdate } = setup({
+ agent,
+ agentId: ref('agent-1'),
+ beforeAgentMutation,
+ });
+ scheduleConfigUpdate.mockImplementation(() => events.push('config'));
+ localConfig.value = makeConfig({
+ skills: [{ type: 'skill', id: 'tone', versionId: 'v1' }],
+ });
+ actions.onOpenSkillFromList('tone');
+ const outdatedData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY]
+ .data as unknown as SkillModalData;
+
+ outdatedData.onUseLatestVersion?.('tone');
+ await flushPromises();
+
+ expect(scheduleConfigUpdate).toHaveBeenCalledWith({ skills: [{ type: 'skill', id: 'tone' }] });
+ expect(events).toEqual(['config', 'flush']);
+ expect(agent.value?.skills.tone).toEqual(latest);
+ expect(agent.value?.skillHashes?.tone).toBe('hash-latest');
+ expect(agent.value?.outdatedSkillIds).toEqual([]);
+ const editData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
+ expect(editData.skill).toEqual(latest);
+ expect(editData.outdated).toBe(false);
+ });
+
+ function openEdit(options: { skillsHubEnabled?: boolean } = {}) {
+ const result = setupHub(options);
+ result.actions.onOpenSkillFromList('tone');
+ const editData = result.uiStore.modalsById[AGENT_SKILL_MODAL_KEY]
+ .data as unknown as SkillModalData;
+ return { ...result, editData };
+ }
+
+ it('saves the content and the target of an edited skill in one skill save', () => {
+ const { editData, scheduleSkillSave } = openEdit();
+
+ editData.onConfirm({ id: 'tone', skill: supportTone, availableTo: 'agent' });
+
+ expect(scheduleSkillSave).toHaveBeenCalledWith({
+ skillId: 'tone',
+ skill: supportTone,
+ availableTo: 'agent',
+ });
+ });
+
+ it('saves no target when the edit keeps it or the skills hub is off', () => {
+ const kept = openEdit();
+ kept.editData.onConfirm({ id: 'tone', skill: supportTone });
+ const hubOff = openEdit({ skillsHubEnabled: false });
+ hubOff.editData.onConfirm({ id: 'tone', skill: supportTone, availableTo: 'agent' });
+
+ expect(kept.scheduleSkillSave).toHaveBeenCalledWith({ skillId: 'tone', skill: supportTone });
+ expect(hubOff.scheduleSkillSave).toHaveBeenCalledWith({ skillId: 'tone', skill: supportTone });
+ });
+
+ it('tells the edit modal to check the edit permission', () => {
+ const { actions, uiStore } = setupHub();
+
+ actions.onOpenSkillFromList('tone');
+
+ const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
+ expect(modalData.skillId).toBe('tone');
+ expect(modalData.hubSkillsEnabled).toBe(true);
+ });
+
+ it('never enables the hub for a host that keeps its own skill bodies', () => {
+ const localConfig = ref(makeConfig());
+ setActivePinia(createTestingPinia({ stubActions: false }));
+ const uiStore = useUIStore();
+ const actions = useAgentCapabilitiesActions({
+ localConfig,
+ agent: ref(null),
+ projectId: computed(() => 'project-1'),
+ agentId: computed(() => 'inline:node-1'),
+ connectedTriggers: ref([]),
+ scheduleConfigUpdate: vi.fn(),
+ scheduleSkillSave: vi.fn(),
+ localSkills: { bodies: computed(() => ({})), createSkill: vi.fn(), updateSkill: vi.fn() },
+ });
+
+ actions.onOpenAddSkillModal();
+
+ const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
+ expect(modalData.hubSkillsEnabled).toBe(false);
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/agents/agents-design-language.md b/packages/frontend/editor-ui/src/features/agents/agents-design-language.md
index 2521b872b05..a9de68438e7 100644
--- a/packages/frontend/editor-ui/src/features/agents/agents-design-language.md
+++ b/packages/frontend/editor-ui/src/features/agents/agents-design-language.md
@@ -184,7 +184,7 @@ text and `3xs` spacing. Keep warning and failure indicators distinct.
### Multi-step contract
Use the multi-step component for Channels, Sub-agents, Tools, MCP servers,
-Workflows, and Vector stores.
+Workflows, Vector stores, and Skills.
For tools, keep selection and configuration separate. The picker owns search,
categories, rows, and connection entry points. After selection, route every
@@ -223,6 +223,40 @@ a direct pill edit. Save, Cancel, and Close exit the complete flow.
| Channel platform setup | A platform-owned setup action can stay in the inline modal content. The Agent shell still owns navigation and dismissal. |
| Memory | Do not migrate Memory into this pattern in this change. |
+When the skills hub is on, `Add skill` starts with the `Skills` picker. The
+picker shows only skills that the Agent can use: skills of its project, skills
+of this Agent only, and global skills. Group them in the tabs All, the project
+name, This agent, and Global. Do not
+show a scope label or an icon in a row. Each row has a switch with the label
+`Attached` or `Detached`. The switch saves at once and keeps the picker open.
+This is an exception to the explicit row action. Disable a skill whose name is
+hard to tell apart from an attached skill, and give the reason in its tooltip.
+A row click opens a read-only preview with Back, Cancel, and `Add skill`. The
+preview of an attached skill shows `Detach` instead of `Add skill`. Both
+actions return to the picker. Do not edit a skill from the picker.
+`Create skill` shows on every tab. It opens the upload screen, and Back returns
+to the picker. The first field of the skill form is `Where can this skill be
+added?`: `This project`, `This agent only`, or `Global`. Each choice has an
+icon. The field starts from the tab: the project tab starts at `This project`,
+the Global tab at `Global`, and the other tabs at `This agent only`. Only a
+user who can create global skills sees `Global`. An existing skill that the
+user can edit shows the same field. Disable it, with a tooltip that gives the
+reason, when another Agent uses the skill. `Save skill` stays disabled until
+the user changes the skill or its target. Do not show the field when the
+skills hub is off. Then `Add skill` creates the skill on the Agent, without the picker.
+
+Show a read-only skill as a document, not as a disabled form. Show each label
+in muted text without the required mark, the description as text, and the
+instructions and references as rendered markdown. Put a muted hint before Cancel that tells the
+user how to edit the skill, or whom to ask. A skill that the user can use but
+not edit opens this way. Hide Save. Keep `Detach` for an attached skill, and
+`Add skill` for a preview of a skill that is not attached.
+
+A skill that the draft pins to an older version also opens read-only, with
+the hint that the agent uses an older version. Its primary action is
+`Use latest version`. The action removes the pin, saves the draft, and opens
+the latest version in the editor.
+
New skills start with an upload screen. Make `Upload folder` the primary
action. Support a single `SKILL.md` file and folder drops. Put `Add manually`
below the drop zone, inline with the prompt. Keep the same font and underline.
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChipButton.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChipButton.vue
index 8b74f3c3994..d66942502db 100644
--- a/packages/frontend/editor-ui/src/features/agents/components/AgentChipButton.vue
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChipButton.vue
@@ -140,6 +140,7 @@ const emit = defineEmits<{
}
.alertIcon {
+ display: block;
flex-shrink: 0;
}
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentSkillFileNav.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentSkillFileNav.vue
index a966f28ca50..60cc0b2be7e 100644
--- a/packages/frontend/editor-ui/src/features/agents/components/AgentSkillFileNav.vue
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentSkillFileNav.vue
@@ -12,9 +12,11 @@ const props = withDefaults(
skill: AgentSkill;
selectedPath: string;
addReferenceDisabled?: boolean;
+ readOnly?: boolean;
}>(),
{
addReferenceDisabled: false,
+ readOnly: false,
},
);
@@ -59,6 +61,7 @@ function testIdForPath(path: string) {
{{ i18n.baseText('agents.builder.skills.references.title') }}
{{ reference.path }}
-import { computed, ref } from 'vue';
+import cloneDeep from 'lodash/cloneDeep';
+import isEqual from 'lodash/isEqual';
+import { computed, onMounted, ref } from 'vue';
import {
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
AGENT_SKILL_REFERENCE_MAX_COUNT,
+ skillNameKey,
+ type AgentSkillAvailability,
+ type SkillDetail,
+ type SkillListItem,
} from '@n8n/api-types';
-import { N8nButton, N8nCallout, N8nIcon } from '@n8n/design-system';
+import { useToast } from '@n8n/composables/useToast';
+import {
+ N8nButton,
+ N8nCallout,
+ N8nIcon,
+ N8nInputLabel,
+ N8nOption,
+ N8nSelect,
+ N8nText,
+ N8nTooltip,
+} from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
+import { useRootStore } from '@n8n/stores/useRootStore';
import { useUIStore } from '@/app/stores/ui.store';
+import ToolsConnectionModal from '@/features/shared/toolsConnection/ToolsConnectionModal.vue';
+import type {
+ SkillConnectionItem,
+ ToolCategoryKey,
+ ToolConnectionItem,
+} from '@/features/shared/toolsConnection/types';
+import { getHubSkill, listAttachableSkills } from '../composables/useAgentApi';
import { useAgentTelemetry } from '../composables/useAgentTelemetry';
import type { AgentSkill } from '../types';
import { normalizeAgentSkillForSave } from '../utils/agentSkill';
import AgentSkillFileNav from './AgentSkillFileNav.vue';
import AgentSkillUpload from './AgentSkillUpload.vue';
import AgentSkillViewer from './AgentSkillViewer.vue';
-import AgentModal from './modals/AgentModal.vue';
+import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
const SKILL_FILE = 'SKILL.md';
@@ -28,7 +52,18 @@ export type AgentSkillModalData = {
* the modal is still open — skill names must be unique per agent.
*/
existingSkillNames?: string[];
- onConfirm: (payload: { id?: string; skill: AgentSkill }) => void;
+ hubSkillsEnabled?: boolean;
+ getAttachedSkills?: () => Array<{ id: string; name: string }>;
+ projectLabel?: string;
+ canCreateInstanceSkills?: boolean;
+ onConfirm: (payload: {
+ id?: string;
+ skill: AgentSkill;
+ availableTo?: AgentSkillAvailability;
+ }) => void;
+ outdated?: boolean;
+ onUseLatestVersion?: (id: string) => void;
+ onAttach?: (skill: SkillListItem) => Promise;
onRemove?: (id: string) => void;
};
@@ -39,38 +74,162 @@ const props = defineProps<{
const i18n = useI18n();
const uiStore = useUIStore();
+const rootStore = useRootStore();
+const { showError } = useToast();
const agentTelemetry = useAgentTelemetry();
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
+const existingNameKeys = computed(() => {
+ const names = props.data.getAttachedSkills
+ ? props.data.getAttachedSkills().map(({ name }) => name)
+ : (props.data.existingSkillNames ?? []);
+ return new Set(names.map(skillNameKey));
+});
+
function getDefaultSkillName(): string {
const baseName = i18n.baseText('agents.builder.skills.defaultName' as BaseTextKey);
- const existingNames = new Set(
- (props.data.existingSkillNames ?? []).map((name) => name.trim().toLowerCase()),
- );
- if (!existingNames.has(baseName.toLowerCase())) return baseName;
+ if (!existingNameKeys.value.has(skillNameKey(baseName))) return baseName;
let suffix = 2;
- while (existingNames.has(`${baseName} ${suffix}`.toLowerCase())) suffix += 1;
+ while (existingNameKeys.value.has(skillNameKey(`${baseName} ${suffix}`))) suffix += 1;
return `${baseName} ${suffix}`;
}
-const skill = ref(
- normalizeSkill({
- name: props.data.skill?.name ?? getDefaultSkillName(),
- description: props.data.skill?.description ?? '',
- instructions: props.data.skill?.instructions ?? '',
- ...(props.data.skill?.references ? { references: props.data.skill.references } : {}),
- }),
-);
+const initialSkill = normalizeSkill({
+ name: props.data.skill?.name ?? getDefaultSkillName(),
+ description: props.data.skill?.description ?? '',
+ instructions: props.data.skill?.instructions ?? '',
+ ...(props.data.skill?.references ? { references: props.data.skill.references } : {}),
+});
+const skill = ref(cloneDeep(initialSkill));
const submitted = ref(false);
const formIsValid = ref(false);
const selectedPath = ref(SKILL_FILE);
-const step = ref<'upload' | 'manual' | 'edit'>(
- props.data.skill || props.data.skillId ? 'edit' : 'upload',
+const isEditing = computed(() => !!props.data.skillId);
+const hasPicker = !props.data.skillId && !!props.data.hubSkillsEnabled && !!props.data.onAttach;
+const step = ref<'select' | 'preview' | 'upload' | 'manual' | 'edit'>(
+ props.data.skill || props.data.skillId ? 'edit' : hasPicker ? 'select' : 'upload',
);
const isImporting = ref(false);
+const availableTo = ref('project');
+
+const librarySkills = ref([]);
+const libraryLoading = ref(false);
+const attachedSkills = computed(() => props.data.getAttachedSkills?.() ?? []);
+const attachedSkillIds = computed(() => new Set(attachedSkills.value.map(({ id }) => id)));
+const attachingSkillIds = ref(new Set());
+const pickerItems = computed(() =>
+ librarySkills.value.map((librarySkill) => {
+ const attached = attachedSkillIds.value.has(librarySkill.id);
+ const attaching = attachingSkillIds.value.has(librarySkill.id);
+ const similarNameReason =
+ attached || attaching ? undefined : getSimilarNameReason(librarySkill.name);
+ return {
+ id: `skill:${librarySkill.id}`,
+ kind: 'skill',
+ skillId: librarySkill.id,
+ title: librarySkill.name,
+ description: librarySkill.description,
+ category: getPickerCategory(librarySkill),
+ status: 'none',
+ attached: attached || attaching,
+ attaching,
+ disabled: similarNameReason !== undefined,
+ ...(similarNameReason ? { disabledReason: similarNameReason } : {}),
+ };
+ }),
+);
+
+const previewSkill = ref(null);
+const previewPath = ref(SKILL_FILE);
+const previewLoading = ref(false);
+const previewIsAdded = computed(
+ () => !!previewSkill.value && attachedSkillIds.value.has(previewSkill.value.id),
+);
+const showRemove = computed(() => {
+ if (!props.data.onRemove) return false;
+ return step.value === 'preview' ? previewIsAdded.value : isEditing.value;
+});
+const footerPrimaryAction = computed<'add' | 'save' | 'useLatest' | undefined>(() => {
+ if (step.value === 'preview') return previewIsAdded.value ? undefined : 'add';
+ if (isOutdated.value) return props.data.onUseLatestVersion ? 'useLatest' : undefined;
+ return isReadOnly.value ? undefined : 'save';
+});
+const modalTitle = computed(() => {
+ if (step.value === 'select') return i18n.baseText('agents.builder.skills.title');
+ if (step.value === 'upload') return i18n.baseText('agents.builder.skills.add');
+ if (step.value === 'preview') return previewSkill.value?.name ?? '';
+ return skill.value.name;
+});
+
+const skillAccess = ref(null);
+const skillAccessLoading = ref(false);
+const skillAccessFailed = ref(false);
+const isOutdated = computed(() => isEditing.value && !!props.data.outdated);
+const isReadOnly = computed(
+ () =>
+ skillAccessLoading.value ||
+ skillAccessFailed.value ||
+ skillAccess.value?.canEdit === false ||
+ isOutdated.value,
+);
+const cannotEdit = computed(() => skillAccess.value?.canEdit === false || isOutdated.value);
+const savedTarget = computed(() => {
+ if (!skillAccess.value) return undefined;
+ if (skillAccess.value.agentId) return 'agent';
+ return skillAccess.value.scope === 'instance' ? 'instance' : 'project';
+});
+const usedByOtherAgents = computed(() => {
+ const usage = skillAccess.value?.usedBy;
+ if (!usage) return false;
+ const isOtherAgent = ({ agentId }: { agentId: string }) => agentId !== props.data.agentId;
+ return usage.hiddenAgents > 0 || usage.drafts.some(isOtherAgent) || usage.pins.some(isOtherAgent);
+});
+const showAvailabilitySelect = computed(() => {
+ if (!props.data.hubSkillsEnabled || step.value === 'preview') return false;
+ if (!isEditing.value) return true;
+ return !!savedTarget.value && !cannotEdit.value;
+});
+const showInstanceTarget = computed(
+ () => !!props.data.canCreateInstanceSkills || savedTarget.value === 'instance',
+);
+const AVAILABILITY_ICONS = {
+ project: 'layers',
+ agent: 'bot',
+ instance: 'globe',
+} as const satisfies Record;
+const availabilityOptions = computed(() => [
+ { value: 'project' as const, label: i18n.baseText('agents.builder.skills.availableTo.project') },
+ { value: 'agent' as const, label: i18n.baseText('agents.builder.skills.availableTo.agent') },
+ ...(showInstanceTarget.value
+ ? [
+ {
+ value: 'instance' as const,
+ label: i18n.baseText('agents.builder.skills.availableTo.instance'),
+ },
+ ]
+ : []),
+]);
+const removeLabel = computed(() =>
+ props.data.hubSkillsEnabled
+ ? i18n.baseText('agents.builder.skills.detach')
+ : i18n.baseText('agents.builder.skills.remove'),
+);
+const footerHint = computed(() => {
+ const shownSkill = step.value === 'preview' ? previewSkill.value : skillAccess.value;
+ if (step.value === 'edit' && isOutdated.value) {
+ return i18n.baseText('agents.builder.skills.hint.outdated');
+ }
+ if (!shownSkill || (step.value !== 'preview' && step.value !== 'edit')) return '';
+ if (!shownSkill.canEdit) {
+ return shownSkill.scope === 'instance'
+ ? i18n.baseText('agents.builder.skills.hint.readOnly.instance')
+ : i18n.baseText('agents.builder.skills.hint.readOnly.project');
+ }
+ if (step.value !== 'preview' || previewIsAdded.value) return '';
+ return i18n.baseText('agents.builder.skills.hint.addToEdit');
+});
-const isEditing = computed(() => !!props.data.skillId);
const canAddReference = computed(
() => (skill.value.references ?? []).length < AGENT_SKILL_REFERENCE_MAX_COUNT,
);
@@ -93,11 +252,7 @@ const validationErrors = computed>>(()
errors.name = i18n.baseText('agents.builder.skills.validation.nameRequired');
} else if (name.length > 128) {
errors.name = i18n.baseText('agents.builder.skills.validation.nameMaxLength');
- } else if (
- (props.data.existingSkillNames ?? []).some(
- (existingName) => existingName.trim().toLowerCase() === name.toLowerCase(),
- )
- ) {
+ } else if (existingNameKeys.value.has(skillNameKey(name))) {
errors.name = i18n.baseText('agents.builder.skills.validation.nameDuplicate');
}
@@ -127,7 +282,115 @@ const visibleErrors = computed(() =>
const visibleNameError = computed(() =>
submitted.value ? (validationErrors.value.name ?? '') : '',
);
-const canSave = computed(() => formIsValid.value && !validationErrors.value.name);
+const canSave = computed(
+ () => formIsValid.value && !validationErrors.value.name && !isReadOnly.value,
+);
+const hasChanges = computed(
+ () =>
+ !isEditing.value ||
+ !isEqual(skill.value, initialSkill) ||
+ (showAvailabilitySelect.value && availableTo.value !== savedTarget.value),
+);
+
+function getSimilarNameReason(name: string): string | undefined {
+ const key = skillNameKey(name);
+ const similarNames = attachedSkills.value
+ .map((attachedSkill) => attachedSkill.name)
+ .filter((attachedName) => skillNameKey(attachedName) === key);
+ if (similarNames.length === 0) return undefined;
+ return i18n.baseText('agents.builder.skills.picker.similarName', {
+ interpolate: { names: [...similarNames, name].map((value) => `"${value}"`).join(', ') },
+ });
+}
+
+function getPickerCategory(librarySkill: SkillListItem): 'project' | 'this-agent' | 'global' {
+ if (librarySkill.scope === 'instance') return 'global';
+ return librarySkill.agentId ? 'this-agent' : 'project';
+}
+
+async function loadLibrarySkills() {
+ libraryLoading.value = true;
+ try {
+ librarySkills.value = await listAttachableSkills(rootStore.restApiContext, props.data.agentId);
+ } catch (error) {
+ showError(error, i18n.baseText('agents.builder.skills.picker.loadError'));
+ } finally {
+ libraryLoading.value = false;
+ }
+}
+
+function targetForPickerCategory(category: ToolCategoryKey): AgentSkillAvailability {
+ if (category === 'project') return 'project';
+ if (category === 'global' && props.data.canCreateInstanceSkills) return 'instance';
+ return 'agent';
+}
+
+function onPickerCreate(category: ToolCategoryKey) {
+ availableTo.value = targetForPickerCategory(category);
+ step.value = 'upload';
+}
+
+async function loadSkillAccess(skillId: string) {
+ skillAccessLoading.value = true;
+ try {
+ skillAccess.value = await getHubSkill(rootStore.restApiContext, skillId);
+ availableTo.value = savedTarget.value ?? 'project';
+ } catch (error) {
+ skillAccess.value = null;
+ skillAccessFailed.value = true;
+ showError(error, i18n.baseText('agents.builder.skills.accessError'));
+ } finally {
+ skillAccessLoading.value = false;
+ }
+}
+
+function onPickerItemAttach(item: ToolConnectionItem) {
+ if (item.kind !== 'skill' || item.disabled || item.attached) return;
+ void attachLibrarySkill(item.skillId);
+}
+
+function onPickerItemDetach(item: ToolConnectionItem) {
+ if (item.kind !== 'skill' || !item.attached || item.attaching) return;
+ props.data.onRemove?.(item.skillId);
+}
+
+async function onPickerItemPreview(item: ToolConnectionItem) {
+ if (item.kind !== 'skill' || item.disabled) return;
+ previewLoading.value = true;
+ try {
+ previewSkill.value = await getHubSkill(rootStore.restApiContext, item.skillId);
+ previewPath.value = SKILL_FILE;
+ step.value = 'preview';
+ } catch (error) {
+ showError(error, i18n.baseText('agents.builder.skills.picker.previewError'));
+ } finally {
+ previewLoading.value = false;
+ }
+}
+
+async function attachLibrarySkill(skillId: string) {
+ const librarySkill = librarySkills.value.find((candidate) => candidate.id === skillId);
+ if (!librarySkill || !props.data.onAttach) return;
+ attachingSkillIds.value = new Set([...attachingSkillIds.value, skillId]);
+ try {
+ await props.data.onAttach(librarySkill);
+ } finally {
+ const remaining = new Set(attachingSkillIds.value);
+ remaining.delete(skillId);
+ attachingSkillIds.value = remaining;
+ }
+}
+
+function onPreviewAdd() {
+ if (!previewSkill.value) return;
+ void attachLibrarySkill(previewSkill.value.id);
+ step.value = 'select';
+}
+
+onMounted(() => {
+ if (hasPicker) void loadLibrarySkills();
+ if (props.data.skillId && props.data.hubSkillsEnabled) void loadSkillAccess(props.data.skillId);
+});
function onSkillUploaded(importedSkill: AgentSkill) {
skill.value = normalizeSkill(importedSkill);
@@ -137,6 +400,10 @@ function onSkillUploaded(importedSkill: AgentSkill) {
}
function onBack() {
+ if (step.value === 'upload' || step.value === 'preview') {
+ step.value = 'select';
+ return;
+ }
skill.value = { name: getDefaultSkillName(), description: '', instructions: '' };
selectedPath.value = SKILL_FILE;
submitted.value = false;
@@ -145,6 +412,7 @@ function onBack() {
}
function onSkillUpdate(updates: Partial) {
+ if (isReadOnly.value) return;
skill.value = normalizeSkill({ ...skill.value, ...updates });
if (
selectedPath.value !== SKILL_FILE &&
@@ -221,11 +489,28 @@ function onSave() {
...(skill.value.references ? { references: skill.value.references } : {}),
});
- props.data.onConfirm({ id: props.data.skillId, skill: payload });
+ const targetChanged = !isEditing.value || availableTo.value !== savedTarget.value;
+ props.data.onConfirm({
+ id: props.data.skillId,
+ skill: payload,
+ ...(showAvailabilitySelect.value && targetChanged ? { availableTo: availableTo.value } : {}),
+ });
closeModal();
}
+function onUseLatestVersion() {
+ if (!props.data.skillId) return;
+ const skillId = props.data.skillId;
+ closeModal();
+ props.data.onUseLatestVersion?.(skillId);
+}
+
function onRemove() {
+ if (step.value === 'preview') {
+ if (previewSkill.value) props.data.onRemove?.(previewSkill.value.id);
+ step.value = 'select';
+ return;
+ }
if (!props.data.skillId) return;
props.data.onRemove?.(props.data.skillId);
closeModal();
@@ -233,72 +518,200 @@ function onRemove() {
-
-
- {{ i18n.baseText('agents.builder.skills.missingContent.callout' as BaseTextKey) }}
-
-
+
+
+
+ {{ i18n.baseText('agents.builder.skills.missingContent.callout' as BaseTextKey) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+
+
+
-
+
-
- {{ i18n.baseText('agents.builder.skills.remove') }}
+
+ {{ removeLabel }}
-
-
+
+
+ {{ footerHint }}
+
+
+
+
+ {{ i18n.baseText('agents.builder.skills.picker.add') }}
+
+
+ {{ i18n.baseText('agents.builder.skills.useLatest') }}
+
+
{{ i18n.baseText('agents.builder.skills.save') }}
-
+