From 4d5287520a6430683d38a8e4ebf7f48ddeac2e97 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 15 Dec 2025 18:36:27 -0800 Subject: [PATCH] feat: update Skill tool to support load/unload commands (#219) Co-authored-by: Letta --- src/agent/memory.ts | 14 +- src/agent/prompts/letta_claude.md | 7 +- src/agent/prompts/letta_codex.md | 7 +- src/agent/prompts/letta_gemini.md | 7 +- src/agent/prompts/loaded_skills.mdx | 4 +- src/agent/prompts/skill_creator_mode.md | 7 +- src/agent/prompts/skill_unload_reminder.txt | 3 +- src/agent/prompts/skills.mdx | 4 +- src/agent/prompts/system_prompt.txt | 7 +- src/tools/descriptions/Skill.md | 23 +- src/tools/impl/Skill.ts | 349 +++++++++++++++----- src/tools/schemas/Skill.json | 14 +- 12 files changed, 334 insertions(+), 112 deletions(-) diff --git a/src/agent/memory.ts b/src/agent/memory.ts index 947f960..d1a41c5 100644 --- a/src/agent/memory.ts +++ b/src/agent/memory.ts @@ -16,6 +16,12 @@ export const PROJECT_BLOCK_LABELS = [ "loaded_skills", ] as const; +/** + * Block labels that should be read-only (agent cannot modify via memory tools). + * These blocks are managed by specific tools (e.g., Skill tool for skills/loaded_skills). + */ +export const READ_ONLY_BLOCK_LABELS = ["skills", "loaded_skills"] as const; + /** * Check if a block label is a project-level block */ @@ -77,8 +83,9 @@ async function loadMemoryBlocksFromMdx(): Promise { } const { frontmatter, body } = parseMdxFrontmatter(content); + const label = frontmatter.label || filename.replace(".mdx", ""); const block: CreateBlock = { - label: frontmatter.label || filename.replace(".mdx", ""), + label, value: body, }; @@ -86,6 +93,11 @@ async function loadMemoryBlocksFromMdx(): Promise { block.description = frontmatter.description; } + // Set read-only for skills blocks (managed by Skill tool, not memory tools) + if ((READ_ONLY_BLOCK_LABELS as readonly string[]).includes(label)) { + block.read_only = true; + } + memoryBlocks.push(block); } catch (error) { console.error(`Error loading ${filename}:`, error); diff --git a/src/agent/prompts/letta_claude.md b/src/agent/prompts/letta_claude.md index 29f9d68..7bf8072 100644 --- a/src/agent/prompts/letta_claude.md +++ b/src/agent/prompts/letta_claude.md @@ -154,7 +154,8 @@ How to store Skills: How to use Skills: - Skills are automatically discovered on bootup. - Review available skills from the `skills` block and loaded skills from the `loaded_skills` block when you are asked to complete a task. -- If any skill is relevant, load it using the `Skill` tool. +- If any skill is relevant, load it using the `Skill` tool with `command: "load"`. - Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed. -- When the task is completed, unload irrelevant skills from the `loaded_skills` block. -IMPORTANT: Always remove irrelevant skills using memory management tools from the `loaded_skills` block. \ No newline at end of file +- When the task is completed, unload irrelevant skills using the Skill tool with `command: "unload"`. +- After creating a new skill, use `command: "refresh"` to re-scan the skills directory and update the available skills list. +IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space. \ No newline at end of file diff --git a/src/agent/prompts/letta_codex.md b/src/agent/prompts/letta_codex.md index 4772b57..ae333b5 100644 --- a/src/agent/prompts/letta_codex.md +++ b/src/agent/prompts/letta_codex.md @@ -139,7 +139,8 @@ How to store Skills: How to use Skills: - Skills are automatically discovered on bootup. - Review available skills from the `skills` block and loaded skills from the `loaded_skills` block when you are asked to complete a task. -- If any skill is relevant, load it using the `Skill` tool. +- If any skill is relevant, load it using the `Skill` tool with `command: "load"`. - Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed. -- When the task is completed, unload irrelevant skills from the `loaded_skills` block. -IMPORTANT: Always remove irrelevant skills using memory management tools from the `loaded_skills` block. \ No newline at end of file +- When the task is completed, unload irrelevant skills using the Skill tool with `command: "unload"`. +- After creating a new skill, use `command: "refresh"` to re-scan the skills directory and update the available skills list. +IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space. \ No newline at end of file diff --git a/src/agent/prompts/letta_gemini.md b/src/agent/prompts/letta_gemini.md index 3ab0794..a24721a 100644 --- a/src/agent/prompts/letta_gemini.md +++ b/src/agent/prompts/letta_gemini.md @@ -100,7 +100,8 @@ How to store Skills: How to use Skills: - Skills are automatically discovered on bootup. - Review available skills from the `skills` block and loaded skills from the `loaded_skills` block when you are asked to complete a task. -- If any skill is relevant, load it using the `Skill` tool. +- If any skill is relevant, load it using the `Skill` tool with `command: "load"`. - Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed. -- When the task is completed, unload irrelevant skills from the `loaded_skills` block. -IMPORTANT: Always remove irrelevant skills using memory management tools from the `loaded_skills` block. \ No newline at end of file +- When the task is completed, unload irrelevant skills using the Skill tool with `command: "unload"`. +- After creating a new skill, use `command: "refresh"` to re-scan the skills directory and update the available skills list. +IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space. \ No newline at end of file diff --git a/src/agent/prompts/loaded_skills.mdx b/src/agent/prompts/loaded_skills.mdx index 76a14a0..052fbd3 100644 --- a/src/agent/prompts/loaded_skills.mdx +++ b/src/agent/prompts/loaded_skills.mdx @@ -1,6 +1,6 @@ --- label: loaded_skills -description: A memory block to store the full instructions and capabilities from each loaded SKILL.md file in this block. +description: A memory block to store the full instructions and capabilities from each loaded SKILL.md file in this block. Do not manually edit this block - use the Skill tool to load and unload skills. --- -[CURRENTLY EMPTY] \ No newline at end of file +[CURRENTLY EMPTY] diff --git a/src/agent/prompts/skill_creator_mode.md b/src/agent/prompts/skill_creator_mode.md index 8b0478b..8306ac7 100644 --- a/src/agent/prompts/skill_creator_mode.md +++ b/src/agent/prompts/skill_creator_mode.md @@ -16,7 +16,7 @@ Your goal is to guide the user through a **focused, collaborative workflow** to - `loaded_skills` – SKILL.md contents for currently loaded skills 2. If a `skill-creator` skill is **not already loaded** in `loaded_skills`, you should **attempt to load it** using the `Skill` tool: - Call the `Skill` tool with: - - `skill: "skill-creator"` + - `command: "load", skills: ["skill-creator"]` - The environment may resolve this from either the project’s `.skills` directory or a bundled `skills/skills/skill-creator/SKILL.md` location. 3. If loading `skill-creator` fails (for example, the tool errors or the file is missing), or if the environment does not provide it, continue using your own judgment based on these instructions. @@ -118,6 +118,5 @@ Your goal is to: 1. Understand the desired skill thoroughly. 2. Propose a clear, reusable design. 3. Implement or update the actual skill files in the repository. -4. Leave the user with a ready‑to‑use skill that appears in the `skills` memory block and can be loaded with the `Skill` tool. - - +4. After creating or updating the skill files, use the `Skill` tool with `command: "refresh"` to re-scan the skills directory and update the `skills` memory block. +5. Leave the user with a ready‑to‑use skill that appears in the `skills` memory block and can be loaded with the `Skill` tool. \ No newline at end of file diff --git a/src/agent/prompts/skill_unload_reminder.txt b/src/agent/prompts/skill_unload_reminder.txt index 4f8088a..787720b 100644 --- a/src/agent/prompts/skill_unload_reminder.txt +++ b/src/agent/prompts/skill_unload_reminder.txt @@ -1,6 +1,5 @@ The `loaded_skills` block has at least one skill loaded. You should: 1. Check if loaded skills are relevant for the current task. -2. For any skills that are irrelevant, unload them using the `memory` tool. -If the block will be empty after unloading, add a "[CURRENTLY EMPTY]" tag. +2. For any skills that are irrelevant, unload them using the `Skill` tool with `command: "unload"`. diff --git a/src/agent/prompts/skills.mdx b/src/agent/prompts/skills.mdx index 6f24260..9c94f3c 100644 --- a/src/agent/prompts/skills.mdx +++ b/src/agent/prompts/skills.mdx @@ -1,6 +1,6 @@ --- label: skills -description: A memory block to store all available Skills with their metadata (name and description). Whenever a new Skill is discovered / created or an existing Skill is updated, I should store it here. I should always check the `.skills` directory for an updated skill list. +description: A memory block listing all available skills with their metadata (name and description). This block is auto-generated based on the `.skills` directory. Do not manually edit this block. --- -[CURRENTLY EMPTY: CREATE A LIST OF AVAILABLE SKILLS (DIRECTORIES WITH SKILL.MD FILE) WITH DESCRIPTIONS IN THIS MEMORY BLOCK. ALWAYS CHECK THE `.skills` DIRECTORY TO MAKE SURE YOU HAVE AN UPDATED LIST OF SKILLS] \ No newline at end of file +[CURRENTLY EMPTY] diff --git a/src/agent/prompts/system_prompt.txt b/src/agent/prompts/system_prompt.txt index 897abc9..82beadd 100644 --- a/src/agent/prompts/system_prompt.txt +++ b/src/agent/prompts/system_prompt.txt @@ -35,7 +35,8 @@ How to store Skills: How to use Skills: - Skills are automatically discovered on bootup. - Review available skills from the `skills` block and loaded skills from the `loaded_skills` block when you are asked to complete a task. -- If any skill is relevant, load it using the `Skill` tool. +- If any skill is relevant, load it using the `Skill` tool with `command: "load"`. - Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed. -- When the task is completed, unload irrelevant skills from the `loaded_skills` block. -IMPORTANT: Always remove irrelevant skills using memory management tools from the `loaded_skills` block. \ No newline at end of file +- When the task is completed, unload irrelevant skills using the Skill tool with `command: "unload"`. +- After creating a new skill, use `command: "refresh"` to re-scan the skills directory and update the available skills list. +IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space. \ No newline at end of file diff --git a/src/tools/descriptions/Skill.md b/src/tools/descriptions/Skill.md index 3672686..a857257 100644 --- a/src/tools/descriptions/Skill.md +++ b/src/tools/descriptions/Skill.md @@ -1,28 +1,35 @@ # Skill -Load a skill into the system prompt within the main conversation +Load or unload skills into the agent's memory. When users ask you to perform tasks, check if any of the available skills can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. How to use skills: -- Invoke skills using this tool with the skill name only (no arguments) -- When you invoke a skill, the SKILL.md content will be loaded into the `loaded_skills` memory block +- Use `command: "load"` with a list of skill IDs to load skills +- Use `command: "unload"` with a list of skill IDs to unload skills +- Use `command: "refresh"` to re-scan the skills directory and update the available skills list +- When you load a skill, the SKILL.md content will be added to the `loaded_skills` memory block - The skill's prompt will provide detailed instructions on how to complete the task - Examples: - - `skill: "data-analysis"` - invoke the data-analysis skill - - `skill: "web-scraper"` - invoke the web-scraper skill + - `command: "load", skills: ["data-analysis"]` - load the data-analysis skill + - `command: "load", skills: ["web-scraper", "pdf"]` - load multiple skills + - `command: "unload", skills: ["data-analysis"]` - unload the data-analysis skill + - `command: "refresh"` - re-scan and update available skills list Important: - Only load skills that are available in the `skills` memory block -- Skills remain loaded until you unload them - Unload skills when done to free up context space -- Do not invoke a skill that is already loaded - You can check what skills are currently loaded in the `loaded_skills` memory block +- Loading an already-loaded skill will be skipped (no error) +- Unloading a not-loaded skill will be skipped (no error) +- Use `refresh` after creating a new skill to make it available for loading Usage notes: -- The `skill` parameter is required and should be the skill ID (e.g., "data-analysis") +- The `command` parameter is required: either "load", "unload", or "refresh" +- The `skills` parameter is required for load/unload: an array of skill IDs to load or unload +- The `skills` parameter is not used for refresh - Skills are loaded from the skills directory specified in the `skills` memory block - Skills remain loaded in the `loaded_skills` memory block until explicitly unloaded - Only use skill IDs that appear in the `skills` memory block diff --git a/src/tools/impl/Skill.ts b/src/tools/impl/Skill.ts index 0051da7..f20a5a4 100644 --- a/src/tools/impl/Skill.ts +++ b/src/tools/impl/Skill.ts @@ -6,11 +6,16 @@ import { getSkillsDirectory, setHasLoadedSkills, } from "../../agent/context"; -import { SKILLS_DIR } from "../../agent/skills"; +import { + discoverSkills, + formatSkillsForMemory, + SKILLS_DIR, +} from "../../agent/skills"; import { validateRequiredParams } from "./validation.js"; interface SkillArgs { - skill: string; + command: "load" | "unload" | "refresh"; + skills?: string[]; } interface SkillResult { @@ -18,13 +23,63 @@ interface SkillResult { } /** - * Parse loaded_skills block content to extract skill IDs + * Parse loaded_skills block content to extract skill IDs and their content boundaries */ -function parseLoadedSkills(value: string): string[] { +function parseLoadedSkills( + value: string, +): Map { + const skillMap = new Map(); + const skillHeaderRegex = /# Skill: ([^\n]+)/g; + + const headers: { id: string; start: number }[] = []; + + // Find all skill headers + let match = skillHeaderRegex.exec(value); + while (match !== null) { + const skillId = match[1]?.trim(); + if (skillId) { + headers.push({ id: skillId, start: match.index }); + } + match = skillHeaderRegex.exec(value); + } + + // Determine boundaries for each skill + for (let i = 0; i < headers.length; i++) { + const current = headers[i]; + const next = headers[i + 1]; + + if (!current) continue; + + let end: number; + if (next) { + // Find the separator before the next skill + const searchStart = current.start; + const searchEnd = next.start; + const substring = value.substring(searchStart, searchEnd); + const sepMatch = substring.lastIndexOf("\n\n---\n\n"); + if (sepMatch !== -1) { + end = searchStart + sepMatch; + } else { + end = searchEnd; + } + } else { + end = value.length; + } + + skillMap.set(current.id, { start: current.start, end }); + } + + return skillMap; +} + +/** + * Get list of loaded skill IDs + */ +function getLoadedSkillIds(value: string): string[] { const skillRegex = /# Skill: ([^\n]+)/g; const skills: string[] = []; - let match: RegExpExecArray | null = skillRegex.exec(value); + let match = skillRegex.exec(value); while (match !== null) { const skillId = match[1]?.trim(); if (skillId) { @@ -44,16 +99,115 @@ function extractSkillsDir(skillsBlockValue: string): string | null { return match ? match[1]?.trim() || null : null; } +/** + * Read skill content from file + */ +async function readSkillContent( + skillId: string, + skillsDir: string, +): Promise { + // Try primary skills directory + const skillPath = join(skillsDir, skillId, "SKILL.md"); + + try { + return await readFile(skillPath, "utf-8"); + } catch (primaryError) { + // Fallback: check for bundled skills in a repo-level skills directory + try { + const bundledSkillsDir = join(process.cwd(), "skills", "skills"); + const bundledSkillPath = join(bundledSkillsDir, skillId, "SKILL.md"); + return await readFile(bundledSkillPath, "utf-8"); + } catch { + // If bundled fallback also fails, rethrow the original error + throw primaryError; + } + } +} + +/** + * Get skills directory, trying multiple sources + */ +async function getResolvedSkillsDir( + client: ReturnType, + agentId: string, +): Promise { + let skillsDir = getSkillsDirectory(); + + if (!skillsDir) { + // Try to extract from skills block + try { + const skillsBlock = await client.agents.blocks.retrieve("skills", { + agent_id: agentId, + }); + if (skillsBlock?.value) { + skillsDir = extractSkillsDir(skillsBlock.value); + } + } catch { + // Skills block doesn't exist, will fall back to default + } + } + + if (!skillsDir) { + // Fall back to default .skills directory in cwd + skillsDir = join(process.cwd(), SKILLS_DIR); + } + + return skillsDir; +} + export async function skill(args: SkillArgs): Promise { - validateRequiredParams(args, ["skill"], "Skill"); - const { skill: skillId } = args; + validateRequiredParams(args, ["command"], "Skill"); + const { command, skills: skillIds } = args; + + if (command !== "load" && command !== "unload" && command !== "refresh") { + throw new Error( + `Invalid command "${command}". Must be "load", "unload", or "refresh".`, + ); + } + + // For load/unload, skills array is required + if (command !== "refresh") { + if (!Array.isArray(skillIds) || skillIds.length === 0) { + throw new Error( + `Skill tool requires a non-empty 'skills' array for "${command}" command`, + ); + } + } try { // Get current agent context const client = getCurrentClient(); const agentId = getCurrentAgentId(); - // Retrieve the loaded_skills block directly + // Handle refresh command + if (command === "refresh") { + const skillsDir = await getResolvedSkillsDir(client, agentId); + + // Discover skills from directory + const { skills, errors } = await discoverSkills(skillsDir); + + // Log any errors + if (errors.length > 0) { + for (const error of errors) { + console.warn( + `Skill discovery error: ${error.path}: ${error.message}`, + ); + } + } + + // Format and update the skills block + const formattedSkills = formatSkillsForMemory(skills, skillsDir); + await client.agents.blocks.update("skills", { + agent_id: agentId, + value: formattedSkills, + }); + + return { + message: `Refreshed skills list: found ${skills.length} skill(s)${errors.length > 0 ? `, ${errors.length} error(s)` : ""}`, + }; + } + + // Retrieve the loaded_skills block for load/unload let loadedSkillsBlock: Awaited< ReturnType >; @@ -67,86 +221,125 @@ export async function skill(args: SkillArgs): Promise { ); } - // Determine skills directory - let skillsDir = getSkillsDirectory(); + const skillsDir = await getResolvedSkillsDir(client, agentId); - if (!skillsDir) { - // Try to extract from skills block - try { - const skillsBlock = await client.agents.blocks.retrieve("skills", { - agent_id: agentId, - }); - if (skillsBlock?.value) { - skillsDir = extractSkillsDir(skillsBlock.value); - } - } catch { - // Skills block doesn't exist, will fall back to default - } - } - - if (!skillsDir) { - // Fall back to default .skills directory in cwd - skillsDir = join(process.cwd(), SKILLS_DIR); - } - - // Construct path to SKILL.md in the primary skills directory - let skillPath = join(skillsDir, skillId, "SKILL.md"); - - // Read the skill file directly, with a fallback to bundled skills if not found - let skillContent: string; - try { - skillContent = await readFile(skillPath, "utf-8"); - } catch (primaryError) { - // Fallback: check for bundled skills in a repo-level skills directory - try { - const bundledSkillsDir = join(process.cwd(), "skills", "skills"); - const bundledSkillPath = join(bundledSkillsDir, skillId, "SKILL.md"); - skillContent = await readFile(bundledSkillPath, "utf-8"); - // Update path and directory to reflect bundled location for this invocation - skillsDir = bundledSkillsDir; - skillPath = bundledSkillPath; - } catch { - // If bundled fallback also fails, rethrow the original error - throw primaryError; - } - } - - // Parse current loaded_skills block value let currentValue = loadedSkillsBlock.value?.trim() || ""; - const loadedSkills = parseLoadedSkills(currentValue); + const loadedSkillIds = getLoadedSkillIds(currentValue); + const results: string[] = []; - // Check if skill is already loaded - if (loadedSkills.includes(skillId)) { - return { - message: `Skill "${skillId}" is already loaded`, - }; + // skillIds is guaranteed to be non-empty for load/unload (validated above) + const skillsToProcess = skillIds as string[]; + + if (command === "load") { + // Load skills + for (const skillId of skillsToProcess) { + if (loadedSkillIds.includes(skillId)) { + results.push(`"${skillId}" already loaded`); + continue; + } + + try { + const skillContent = await readSkillContent(skillId, skillsDir); + + // Replace placeholder if this is the first skill + if (currentValue === "[CURRENTLY EMPTY]") { + currentValue = ""; + } + + // Append new skill + const separator = currentValue ? "\n\n---\n\n" : ""; + currentValue = `${currentValue}${separator}# Skill: ${skillId}\n${skillContent}`; + loadedSkillIds.push(skillId); + results.push(`"${skillId}" loaded`); + } catch (error) { + results.push( + `"${skillId}" failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // Update the block + await client.agents.blocks.update("loaded_skills", { + agent_id: agentId, + value: currentValue, + }); + + // Update the cached flag + if (loadedSkillIds.length > 0) { + setHasLoadedSkills(true); + } + } else { + // Unload skills + const skillBoundaries = parseLoadedSkills(currentValue); + + // Sort skills to unload by their position (descending) so we can remove from end first + const sortedSkillsToUnload = skillsToProcess + .filter((id) => skillBoundaries.has(id)) + .sort((a, b) => { + const boundaryA = skillBoundaries.get(a); + const boundaryB = skillBoundaries.get(b); + return (boundaryB?.start || 0) - (boundaryA?.start || 0); + }); + + for (const skillId of skillsToProcess) { + if (!loadedSkillIds.includes(skillId)) { + results.push(`"${skillId}" not loaded`); + continue; + } + results.push(`"${skillId}" unloaded`); + } + + // Remove skills from content (in reverse order to maintain indices) + for (const skillId of sortedSkillsToUnload) { + const boundary = skillBoundaries.get(skillId); + if (boundary) { + // Check if there's a separator before this skill + const beforeStart = boundary.start; + let actualStart = beforeStart; + + // Look for preceding separator + const precedingSep = "\n\n---\n\n"; + if (beforeStart >= precedingSep.length) { + const potentialSep = currentValue.substring( + beforeStart - precedingSep.length, + beforeStart, + ); + if (potentialSep === precedingSep) { + actualStart = beforeStart - precedingSep.length; + } + } + + // Remove the skill content + currentValue = + currentValue.substring(0, actualStart) + + currentValue.substring(boundary.end); + } + } + + // Clean up the value + currentValue = currentValue.trim(); + if (currentValue === "") { + currentValue = "[CURRENTLY EMPTY]"; + } + + // Update the block + await client.agents.blocks.update("loaded_skills", { + agent_id: agentId, + value: currentValue, + }); + + // Update the cached flag + const remainingSkills = getLoadedSkillIds(currentValue); + setHasLoadedSkills(remainingSkills.length > 0); } - // Replace placeholder if this is the first skill - if (currentValue === "[CURRENTLY EMPTY]") { - currentValue = ""; - } - - // Append new skill to loaded_skills block - const separator = currentValue ? "\n\n---\n\n" : ""; - const newValue = `${currentValue}${separator}# Skill: ${skillId}\n${skillContent}`; - - // Update the block - await client.agents.blocks.update("loaded_skills", { - agent_id: agentId, - value: newValue, - }); - - // Update the cached flag to indicate skills are loaded - setHasLoadedSkills(true); - return { - message: `Skill "${skillId}" loaded successfully`, + message: results.join(", "), }; } catch (error) { if (error instanceof Error) { throw error; } - throw new Error(`Failed to load skill: ${String(error)}`); + throw new Error(`Failed to ${command} skill(s): ${String(error)}`); } } diff --git a/src/tools/schemas/Skill.json b/src/tools/schemas/Skill.json index 263d1d2..71308b7 100644 --- a/src/tools/schemas/Skill.json +++ b/src/tools/schemas/Skill.json @@ -1,12 +1,20 @@ { "type": "object", "properties": { - "skill": { + "command": { "type": "string", - "description": "The skill name/id (e.g., \"data-analysis\", \"web-scraper\")" + "enum": ["load", "unload", "refresh"], + "description": "The operation to perform: \"load\" to load skills, \"unload\" to unload skills, \"refresh\" to re-scan the skills directory and update the available skills list" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of skill IDs to load or unload (required for load/unload, not used for refresh)" } }, - "required": ["skill"], + "required": ["command"], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" }