feat(skills): support external skill directories via config (#3678)

Add skills.external_dirs config option — a list of additional directories
to scan for skills alongside ~/.hermes/skills/. External dirs are read-only:
skill creation/editing always writes to the local dir. Local skills take
precedence when names collide.

This lets users share skills across tools/agents without copying them into
Hermes's own directory (e.g. ~/.agents/skills, /shared/team-skills).

Changes:
- agent/skill_utils.py: add get_external_skills_dirs() and get_all_skills_dirs()
- agent/prompt_builder.py: scan external dirs in build_skills_system_prompt()
- tools/skills_tool.py: _find_all_skills() and skill_view() search external dirs;
  security check recognizes configured external dirs as trusted
- agent/skill_commands.py: /skill slash commands discover external skills
- hermes_cli/config.py: add skills.external_dirs to DEFAULT_CONFIG
- cli-config.yaml.example: document the option
- tests/agent/test_external_skills.py: 11 tests covering discovery, precedence,
  deduplication, and skill_view for external skills

Requested by community member primco.
This commit is contained in:
Teknium 2026-03-29 00:33:30 -07:00 committed by GitHub
parent 253a9adc72
commit fcd1645223
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 495 additions and 99 deletions

View file

@ -18,6 +18,7 @@ from typing import Optional
from agent.skill_utils import (
extract_skill_conditions,
extract_skill_description,
get_all_skills_dirs,
get_disabled_skill_names,
iter_skill_index_files,
parse_frontmatter,
@ -444,16 +445,23 @@ def build_skills_system_prompt(
mtime/size manifest survives process restarts
Falls back to a full filesystem scan when both layers miss.
External skill directories (``skills.external_dirs`` in config.yaml) are
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
are read-only they appear in the index but new skills are always created
in the local dir. Local skills take precedence when names collide.
"""
hermes_home = get_hermes_home()
skills_dir = hermes_home / "skills"
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
if not skills_dir.exists():
if not skills_dir.exists() and not external_dirs:
return ""
# ── Layer 1: in-process LRU cache ─────────────────────────────────
cache_key = (
str(skills_dir.resolve()),
tuple(str(d) for d in external_dirs),
tuple(sorted(str(t) for t in (available_tools or set()))),
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
)
@ -540,6 +548,56 @@ def build_skills_system_prompt(
category_descriptions,
)
# ── External skill directories ─────────────────────────────────────
# Scan external dirs directly (no snapshot caching — they're read-only
# and typically small). Local skills already in skills_by_category take
# precedence: we track seen names and skip duplicates from external dirs.
seen_skill_names: set[str] = set()
for cat_skills in skills_by_category.values():
for name, _desc in cat_skills:
seen_skill_names.add(name)
for ext_dir in external_dirs:
if not ext_dir.exists():
continue
for skill_file in iter_skill_index_files(ext_dir, "SKILL.md"):
try:
is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
if not is_compatible:
continue
entry = _build_snapshot_entry(skill_file, ext_dir, frontmatter, desc)
skill_name = entry["skill_name"]
if skill_name in seen_skill_names:
continue
if entry["frontmatter_name"] in disabled or skill_name in disabled:
continue
if not _skill_should_show(
extract_skill_conditions(frontmatter),
available_tools,
available_toolsets,
):
continue
seen_skill_names.add(skill_name)
skills_by_category.setdefault(entry["category"], []).append(
(skill_name, entry["description"])
)
except Exception as e:
logger.debug("Error reading external skill %s: %s", skill_file, e)
# External category descriptions
for desc_file in iter_skill_index_files(ext_dir, "DESCRIPTION.md"):
try:
content = desc_file.read_text(encoding="utf-8")
fm, _ = parse_frontmatter(content)
cat_desc = fm.get("description")
if not cat_desc:
continue
rel = desc_file.relative_to(ext_dir)
cat = "/".join(rel.parts[:-1]) if len(rel.parts) > 1 else "general"
category_descriptions.setdefault(cat, str(cat_desc).strip().strip("'\""))
except Exception as e:
logger.debug("Could not read external skill description %s: %s", desc_file, e)
if not skills_by_category:
result = ""
else:

View file

@ -128,7 +128,11 @@ def _build_skill_message(
supporting.append(rel)
if supporting and skill_dir:
skill_view_target = str(skill_dir.relative_to(SKILLS_DIR))
try:
skill_view_target = str(skill_dir.relative_to(SKILLS_DIR))
except ValueError:
# Skill is from an external dir — use the skill name instead
skill_view_target = skill_dir.name
parts.append("")
parts.append("[This skill has supporting files you can load with the skill_view tool:]")
for sf in supporting:
@ -158,38 +162,49 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
_skill_commands = {}
try:
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
if not SKILLS_DIR.exists():
return _skill_commands
from agent.skill_utils import get_external_skills_dirs
disabled = _get_disabled_skill_names()
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
continue
try:
content = skill_md.read_text(encoding='utf-8')
frontmatter, body = _parse_frontmatter(content)
# Skip skills incompatible with the current OS platform
if not skill_matches_platform(frontmatter):
seen_names: set = set()
# Scan local dir first, then external dirs
dirs_to_scan = []
if SKILLS_DIR.exists():
dirs_to_scan.append(SKILLS_DIR)
dirs_to_scan.extend(get_external_skills_dirs())
for scan_dir in dirs_to_scan:
for skill_md in scan_dir.rglob("SKILL.md"):
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
continue
name = frontmatter.get('name', skill_md.parent.name)
# Respect user's disabled skills config
if name in disabled:
try:
content = skill_md.read_text(encoding='utf-8')
frontmatter, body = _parse_frontmatter(content)
# Skip skills incompatible with the current OS platform
if not skill_matches_platform(frontmatter):
continue
name = frontmatter.get('name', skill_md.parent.name)
if name in seen_names:
continue
# Respect user's disabled skills config
if name in disabled:
continue
description = frontmatter.get('description', '')
if not description:
for line in body.strip().split('\n'):
line = line.strip()
if line and not line.startswith('#'):
description = line[:80]
break
seen_names.add(name)
cmd_name = name.lower().replace(' ', '-').replace('_', '-')
_skill_commands[f"/{cmd_name}"] = {
"name": name,
"description": description or f"Invoke the {name} skill",
"skill_md_path": str(skill_md),
"skill_dir": str(skill_md.parent),
}
except Exception:
continue
description = frontmatter.get('description', '')
if not description:
for line in body.strip().split('\n'):
line = line.strip()
if line and not line.startswith('#'):
description = line[:80]
break
cmd_name = name.lower().replace(' ', '-').replace('_', '-')
_skill_commands[f"/{cmd_name}"] = {
"name": name,
"description": description or f"Invoke the {name} skill",
"skill_md_path": str(skill_md),
"skill_dir": str(skill_md.parent),
}
except Exception:
continue
except Exception:
pass
return _skill_commands

View file

@ -158,6 +158,73 @@ def _normalize_string_set(values) -> Set[str]:
return {str(v).strip() for v in values if str(v).strip()}
# ── External skills directories ──────────────────────────────────────────
def get_external_skills_dirs() -> List[Path]:
"""Read ``skills.external_dirs`` from config.yaml and return validated paths.
Each entry is expanded (``~`` and ``${VAR}``) and resolved to an absolute
path. Only directories that actually exist are returned. Duplicates and
paths that resolve to the local ``~/.hermes/skills/`` are silently skipped.
"""
config_path = get_hermes_home() / "config.yaml"
if not config_path.exists():
return []
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
except Exception:
return []
if not isinstance(parsed, dict):
return []
skills_cfg = parsed.get("skills")
if not isinstance(skills_cfg, dict):
return []
raw_dirs = skills_cfg.get("external_dirs")
if not raw_dirs:
return []
if isinstance(raw_dirs, str):
raw_dirs = [raw_dirs]
if not isinstance(raw_dirs, list):
return []
local_skills = (get_hermes_home() / "skills").resolve()
seen: Set[Path] = set()
result: List[Path] = []
for entry in raw_dirs:
entry = str(entry).strip()
if not entry:
continue
# Expand ~ and environment variables
expanded = os.path.expanduser(os.path.expandvars(entry))
p = Path(expanded).resolve()
if p == local_skills:
continue
if p in seen:
continue
if p.is_dir():
seen.add(p)
result.append(p)
else:
logger.debug("External skills dir does not exist, skipping: %s", p)
return result
def get_all_skills_dirs() -> List[Path]:
"""Return all skill directories: local ``~/.hermes/skills/`` first, then external.
The local dir is always first (and always included even if it doesn't exist
yet callers handle that). External dirs follow in config order.
"""
dirs = [get_hermes_home() / "skills"]
dirs.extend(get_external_skills_dirs())
return dirs
# ── Condition extraction ──────────────────────────────────────────────────

View file

@ -402,6 +402,15 @@ skills:
# Set to 0 to disable.
creation_nudge_interval: 15
# External skill directories — share skills across tools/agents without
# copying them into ~/.hermes/skills/. Each path is expanded (~ and ${VAR})
# and resolved to an absolute path. External dirs are read-only: skill
# creation always writes to ~/.hermes/skills/. Local skills take precedence
# when names collide.
# external_dirs:
# - ~/.agents/skills
# - /home/shared/team-skills
# =============================================================================
# Agent Behavior
# =============================================================================

View file

@ -366,6 +366,13 @@ DEFAULT_CONFIG = {
# Never saved to sessions, logs, or trajectories.
"prefill_messages_file": "",
# Skills — external skill directories for sharing skills across tools/agents.
# Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation
# always goes to ~/.hermes/skills/.
"skills": {
"external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"]
},
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
# This section is only needed for hermes-specific overrides; everything else
# (apiKey, workspace, peerName, sessions, enabled) comes from the global config.

View file

@ -0,0 +1,157 @@
"""Tests for external skill directories (skills.external_dirs config)."""
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
@pytest.fixture
def external_skills_dir(tmp_path):
"""Create a temp dir with a sample external skill."""
ext_dir = tmp_path / "external-skills"
skill_dir = ext_dir / "my-external-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: my-external-skill\ndescription: A skill from an external directory\n---\n\n# My External Skill\n\nDo external things.\n"
)
return ext_dir
@pytest.fixture
def hermes_home(tmp_path):
"""Create a minimal HERMES_HOME with config."""
home = tmp_path / ".hermes"
home.mkdir()
(home / "skills").mkdir()
return home
class TestGetExternalSkillsDirs:
def test_empty_config(self, hermes_home):
(hermes_home / "config.yaml").write_text("skills:\n external_dirs: []\n")
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from agent.skill_utils import get_external_skills_dirs
result = get_external_skills_dirs()
assert result == []
def test_nonexistent_dir_skipped(self, hermes_home):
(hermes_home / "config.yaml").write_text(
"skills:\n external_dirs:\n - /nonexistent/path\n"
)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from agent.skill_utils import get_external_skills_dirs
result = get_external_skills_dirs()
assert result == []
def test_valid_dir_returned(self, hermes_home, external_skills_dir):
(hermes_home / "config.yaml").write_text(
f"skills:\n external_dirs:\n - {external_skills_dir}\n"
)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from agent.skill_utils import get_external_skills_dirs
result = get_external_skills_dirs()
assert len(result) == 1
assert result[0] == external_skills_dir.resolve()
def test_duplicate_dirs_deduplicated(self, hermes_home, external_skills_dir):
(hermes_home / "config.yaml").write_text(
f"skills:\n external_dirs:\n - {external_skills_dir}\n - {external_skills_dir}\n"
)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from agent.skill_utils import get_external_skills_dirs
result = get_external_skills_dirs()
assert len(result) == 1
def test_local_skills_dir_excluded(self, hermes_home):
local_skills = hermes_home / "skills"
(hermes_home / "config.yaml").write_text(
f"skills:\n external_dirs:\n - {local_skills}\n"
)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from agent.skill_utils import get_external_skills_dirs
result = get_external_skills_dirs()
assert result == []
def test_no_config_file(self, hermes_home):
# No config.yaml at all
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from agent.skill_utils import get_external_skills_dirs
result = get_external_skills_dirs()
assert result == []
def test_string_value_converted_to_list(self, hermes_home, external_skills_dir):
(hermes_home / "config.yaml").write_text(
f"skills:\n external_dirs: {external_skills_dir}\n"
)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from agent.skill_utils import get_external_skills_dirs
result = get_external_skills_dirs()
assert len(result) == 1
class TestGetAllSkillsDirs:
def test_local_always_first(self, hermes_home, external_skills_dir):
(hermes_home / "config.yaml").write_text(
f"skills:\n external_dirs:\n - {external_skills_dir}\n"
)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from agent.skill_utils import get_all_skills_dirs
result = get_all_skills_dirs()
assert result[0] == hermes_home / "skills"
assert result[1] == external_skills_dir.resolve()
class TestExternalSkillsInFindAll:
def test_external_skills_found(self, hermes_home, external_skills_dir):
(hermes_home / "config.yaml").write_text(
f"skills:\n external_dirs:\n - {external_skills_dir}\n"
)
local_skills = hermes_home / "skills"
with (
patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}),
patch("tools.skills_tool.SKILLS_DIR", local_skills),
):
from tools.skills_tool import _find_all_skills
skills = _find_all_skills()
names = [s["name"] for s in skills]
assert "my-external-skill" in names
def test_local_takes_precedence(self, hermes_home, external_skills_dir):
"""If the same skill name exists locally and externally, local wins."""
local_skills = hermes_home / "skills"
local_skill = local_skills / "my-external-skill"
local_skill.mkdir(parents=True)
(local_skill / "SKILL.md").write_text(
"---\nname: my-external-skill\ndescription: Local version\n---\n\nLocal.\n"
)
(hermes_home / "config.yaml").write_text(
f"skills:\n external_dirs:\n - {external_skills_dir}\n"
)
with (
patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}),
patch("tools.skills_tool.SKILLS_DIR", local_skills),
):
from tools.skills_tool import _find_all_skills
skills = _find_all_skills()
matching = [s for s in skills if s["name"] == "my-external-skill"]
assert len(matching) == 1
assert matching[0]["description"] == "Local version"
class TestExternalSkillView:
def test_skill_view_finds_external(self, hermes_home, external_skills_dir):
(hermes_home / "config.yaml").write_text(
f"skills:\n external_dirs:\n - {external_skills_dir}\n"
)
local_skills = hermes_home / "skills"
with (
patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}),
patch("tools.skills_tool.SKILLS_DIR", local_skills),
):
from tools.skills_tool import skill_view
result = json.loads(skill_view("my-external-skill"))
assert result["success"] is True
assert "external things" in result["content"]

View file

@ -494,7 +494,7 @@ def _is_skill_disabled(name: str, platform: str = None) -> bool:
def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]:
"""Recursively find all skills in ~/.hermes/skills/.
"""Recursively find all skills in ~/.hermes/skills/ and external dirs.
Args:
skip_disabled: If True, return ALL skills regardless of disabled
@ -504,59 +504,68 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]:
Returns:
List of skill metadata dicts (name, description, category).
"""
skills = []
from agent.skill_utils import get_external_skills_dirs
if not SKILLS_DIR.exists():
return skills
skills = []
seen_names: set = set()
# Load disabled set once (not per-skill)
disabled = set() if skip_disabled else _get_disabled_skill_names()
# Scan local dir first, then external dirs (local takes precedence)
dirs_to_scan = []
if SKILLS_DIR.exists():
dirs_to_scan.append(SKILLS_DIR)
dirs_to_scan.extend(get_external_skills_dirs())
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts):
continue
skill_dir = skill_md.parent
try:
content = skill_md.read_text(encoding="utf-8")[:4000]
frontmatter, body = _parse_frontmatter(content)
if not skill_matches_platform(frontmatter):
for scan_dir in dirs_to_scan:
for skill_md in scan_dir.rglob("SKILL.md"):
if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts):
continue
name = frontmatter.get("name", skill_dir.name)[:MAX_NAME_LENGTH]
if name in disabled:
skill_dir = skill_md.parent
try:
content = skill_md.read_text(encoding="utf-8")[:4000]
frontmatter, body = _parse_frontmatter(content)
if not skill_matches_platform(frontmatter):
continue
name = frontmatter.get("name", skill_dir.name)[:MAX_NAME_LENGTH]
if name in seen_names:
continue
if name in disabled:
continue
description = frontmatter.get("description", "")
if not description:
for line in body.strip().split("\n"):
line = line.strip()
if line and not line.startswith("#"):
description = line
break
if len(description) > MAX_DESCRIPTION_LENGTH:
description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
category = _get_category_from_path(skill_md)
seen_names.add(name)
skills.append({
"name": name,
"description": description,
"category": category,
})
except (UnicodeDecodeError, PermissionError) as e:
logger.debug("Failed to read skill file %s: %s", skill_md, e)
continue
except Exception as e:
logger.debug(
"Skipping skill at %s: failed to parse: %s", skill_md, e, exc_info=True
)
continue
description = frontmatter.get("description", "")
if not description:
for line in body.strip().split("\n"):
line = line.strip()
if line and not line.startswith("#"):
description = line
break
if len(description) > MAX_DESCRIPTION_LENGTH:
description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
category = _get_category_from_path(skill_md)
skills.append({
"name": name,
"description": description,
"category": category,
})
except (UnicodeDecodeError, PermissionError) as e:
logger.debug("Failed to read skill file %s: %s", skill_md, e)
continue
except Exception as e:
logger.debug(
"Skipping skill at %s: failed to parse: %s", skill_md, e, exc_info=True
)
continue
return skills
@ -756,7 +765,15 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
JSON string with skill content or error message
"""
try:
if not SKILLS_DIR.exists():
from agent.skill_utils import get_external_skills_dirs
# Build list of all skill directories to search
all_dirs = []
if SKILLS_DIR.exists():
all_dirs.append(SKILLS_DIR)
all_dirs.extend(get_external_skills_dirs())
if not all_dirs:
return json.dumps(
{
"success": False,
@ -768,27 +785,37 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
skill_dir = None
skill_md = None
# Try direct path first (e.g., "mlops/axolotl")
direct_path = SKILLS_DIR / name
if direct_path.is_dir() and (direct_path / "SKILL.md").exists():
skill_dir = direct_path
skill_md = direct_path / "SKILL.md"
elif direct_path.with_suffix(".md").exists():
skill_md = direct_path.with_suffix(".md")
# Search all dirs: local first, then external (first match wins)
for search_dir in all_dirs:
# Try direct path first (e.g., "mlops/axolotl")
direct_path = search_dir / name
if direct_path.is_dir() and (direct_path / "SKILL.md").exists():
skill_dir = direct_path
skill_md = direct_path / "SKILL.md"
break
elif direct_path.with_suffix(".md").exists():
skill_md = direct_path.with_suffix(".md")
break
# Search by directory name
# Search by directory name across all dirs
if not skill_md:
for found_skill_md in SKILLS_DIR.rglob("SKILL.md"):
if found_skill_md.parent.name == name:
skill_dir = found_skill_md.parent
skill_md = found_skill_md
for search_dir in all_dirs:
for found_skill_md in search_dir.rglob("SKILL.md"):
if found_skill_md.parent.name == name:
skill_dir = found_skill_md.parent
skill_md = found_skill_md
break
if skill_md:
break
# Legacy: flat .md files
if not skill_md:
for found_md in SKILLS_DIR.rglob(f"{name}.md"):
if found_md.name != "SKILL.md":
skill_md = found_md
for search_dir in all_dirs:
for found_md in search_dir.rglob(f"{name}.md"):
if found_md.name != "SKILL.md":
skill_md = found_md
break
if skill_md:
break
if not skill_md or not skill_md.exists():
@ -815,12 +842,21 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
ensure_ascii=False,
)
# Security: warn if skill is loaded from outside the trusted skills directory
# Security: warn if skill is loaded from outside trusted directories
# (local skills dir + configured external_dirs are all trusted)
_outside_skills_dir = True
_trusted_dirs = [SKILLS_DIR.resolve()]
try:
skill_md.resolve().relative_to(SKILLS_DIR.resolve())
_outside_skills_dir = False
except ValueError:
_outside_skills_dir = True
_trusted_dirs.extend(d.resolve() for d in all_dirs[1:])
except Exception:
pass
for _td in _trusted_dirs:
try:
skill_md.resolve().relative_to(_td)
_outside_skills_dir = False
break
except ValueError:
continue
# Security: detect common prompt injection patterns
_INJECTION_PATTERNS = [
@ -1058,7 +1094,11 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
if script_files:
linked_files["scripts"] = script_files
rel_path = str(skill_md.relative_to(SKILLS_DIR))
try:
rel_path = str(skill_md.relative_to(SKILLS_DIR))
except ValueError:
# External skill — use path relative to the skill's own parent dir
rel_path = str(skill_md.relative_to(skill_md.parent.parent)) if skill_md.parent.parent else skill_md.name
skill_name = frontmatter.get(
"name", skill_md.stem if not skill_dir else skill_dir.name
)

View file

@ -8,7 +8,9 @@ description: "On-demand knowledge documents — progressive disclosure, agent-ma
Skills are on-demand knowledge documents the agent can load when needed. They follow a **progressive disclosure** pattern to minimize token usage and are compatible with the [agentskills.io](https://agentskills.io/specification) open standard.
All skills live in **`~/.hermes/skills/`** — a single directory that serves as the source of truth. On fresh install, bundled skills are copied from the repo. Hub-installed and agent-created skills also go here. The agent can modify or delete any skill.
All skills live in **`~/.hermes/skills/`** — the primary directory and source of truth. On fresh install, bundled skills are copied from the repo. Hub-installed and agent-created skills also go here. The agent can modify or delete any skill.
You can also point Hermes at **external skill directories** — additional folders scanned alongside the local one. See [External Skill Directories](#external-skill-directories) below.
See also:
@ -164,6 +166,47 @@ Once set, declared env vars are **automatically passed through** to `execute_cod
└── .bundled_manifest # Tracks seeded bundled skills
```
## External Skill Directories
If you maintain skills outside of Hermes — for example, a shared `~/.agents/skills/` directory used by multiple AI tools — you can tell Hermes to scan those directories too.
Add `external_dirs` under the `skills` section in `~/.hermes/config.yaml`:
```yaml
skills:
external_dirs:
- ~/.agents/skills
- /home/shared/team-skills
- ${SKILLS_REPO}/skills
```
Paths support `~` expansion and `${VAR}` environment variable substitution.
### How it works
- **Read-only**: External dirs are only scanned for skill discovery. When the agent creates or edits a skill, it always writes to `~/.hermes/skills/`.
- **Local precedence**: If the same skill name exists in both the local dir and an external dir, the local version wins.
- **Full integration**: External skills appear in the system prompt index, `skills_list`, `skill_view`, and as `/skill-name` slash commands — no different from local skills.
- **Non-existent paths are silently skipped**: If a configured directory doesn't exist, Hermes ignores it without errors. Useful for optional shared directories that may not be present on every machine.
### Example
```text
~/.hermes/skills/ # Local (primary, read-write)
├── devops/deploy-k8s/
│ └── SKILL.md
└── mlops/axolotl/
└── SKILL.md
~/.agents/skills/ # External (read-only, shared)
├── my-custom-workflow/
│ └── SKILL.md
└── team-conventions/
└── SKILL.md
```
All four skills appear in your skill index. If you create a new skill called `my-custom-workflow` locally, it shadows the external version.
## Agent-Managed Skills (skill_manage tool)
The agent can create, update, and delete its own skills via the `skill_manage` tool. This is the agent's **procedural memory** — when it figures out a non-trivial workflow, it saves the approach as a skill for future reuse.