n8n-io/n8n · error · InvalidRuntimeSkillError
Invalid skill at ${sourceDirectory}: ${errors.join('; ')}
Error message
Invalid skill at ${sourceDirectory}: ${errors.join('; ')} What it means
Thrown by validateRuntimeSkillFolder during directory-level structural checks before a skill is parsed. It collects filesystem errors and joins them: the skill folder is a symlink, SKILL.md is a symlink, SKILL.md is not a regular file, or any file under the skill folder is a symlink. These are hard rejections (not warnings) because symlinks break content hashing and can escape the skill root. Wrapped as InvalidRuntimeSkillError.
Source
Thrown at packages/@n8n/agents/src/skills/registry.ts:279
if (lstatSync(skillDir).isSymbolicLink()) {
errors.push(`Skill folder must not be a symlink: ${sourceDirectory}`);
}
const skillFileStat = lstatSync(skillFile);
if (skillFileStat.isSymbolicLink()) {
errors.push(`${RUNTIME_SKILL_FILE_NAME} must not be a symlink`);
}
if (!skillFileStat.isFile()) {
errors.push(`${RUNTIME_SKILL_FILE_NAME} must be a regular file`);
}
const symlinks = collectSymlinks(skillDir);
if (symlinks.length > 0) {
errors.push(`Skill files must not include symlinks: ${symlinks.join(', ')}`);
}
if (errors.length > 0) {
throw new InvalidRuntimeSkillError(`Invalid skill at ${sourceDirectory}: ${errors.join('; ')}`);
}
}
function collectSkillFiles(rootDir: string): string[] {
const out: string[] = [];
walkSkillDirectories(rootDir, out);
return out.sort();
}
function walkSkillDirectories(dir: string, out: string[]) {
for (const entry of readdirSync(dir).sort()) {
if (shouldIgnoreDirectory(entry)) continue;
const absolutePath = join(dir, entry);
const stat = lstatSync(absolutePath);
if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
const skillFile = join(absolutePath, RUNTIME_SKILL_FILE_NAME);View on GitHub (pinned to 5ac6606e81)
Solutions
- Replace the symlinked skill folder with real files (cp -rL to dereference), or move the actual folder into the skills tree.
- If a single file inside the folder is a symlink, copy the target file in place.
- Ensure SKILL.md is a regular file (not a symlink, not a directory/pipe).
- Re-run; the message lists every offending path so you can resolve them one by one.
Example fix
# before — symlinked skill ln -s /shared/skills/billing /app/skills/billing # after — dereference into real files cp -rL /shared/skills/billing /app/skills/billing
Defensive patterns
Strategy: validation
Validate before calling
import { lstatSync, readdirSync } from 'fs';
import { join } from 'path';
function assertNoSymlinks(skillDir: string): void {
const check = (abs: string) => {
const st = lstatSync(abs);
if (st.isSymbolicLink()) throw new Error(`Refusing symlink in skill: ${abs}`);
if (st.isDirectory()) for (const e of readdirSync(abs)) check(join(abs, e));
};
check(skillDir);
}
assertNoSymlinks(skillDir); // before loadRuntimeSkillSourceFromDirectory Type guard
import { lstatSync } from 'fs';
function isRegularSkillFile(skillFile: string): boolean {
const st = lstatSync(skillFile);
return !st.isSymbolicLink() && st.isFile();
} Try / catch
try {
loadRuntimeSkillSourceFromDirectory(rootDir);
} catch (err) {
if (err instanceof InvalidRuntimeSkillError && /must not be a symlink|regular file/i.test(err.message)) {
// replace symlinks with real files (cp -rL), then reload
throw err;
}
throw err;
} Prevention
- Never symlink skill folders into the skills tree; copy real files (cp -rL dereferences).
- In your deploy/build step, resolve symlinks before staging skills.
- Add a CI check that fails if any file under the skills root is a symlink (lstatSync().isSymbolicLink()).
When it happens
Trigger: `ln -s` linking a skill folder into the skills tree; a SKILL.md that is itself a symlink to a template; editor/IDE creating symlinks for assets; a deploy that symlinks shared resources (references/, scripts/) into a skill folder; a SKILL.md replaced by a pipe/socket/non-file.
Common situations: Monorepo symlinking a shared package's skills into the runtime skills dir; `pnpm`/`npm link` style dev setups that introduce symlinks; CI copying via `cp -a` preserving symlinks; a build tool generating symlinked assets.
Related errors
- Access denied: "${excludedSegment}" is excluded from filesys
- Pattern "${pattern}" escapes the base directory
- Invalid skill at ${sourceDirectory}: ${formatSkillValidation
- ${formatSkillValidationErrors(validation.errors)}
- Duplicate skill source directory "${normalizedSkill.sourceDi
AI-assisted analysis of n8n-io/n8n@5ac6606e81 (2026-08-12).
Data as JSON: /api/errors/4468fe477a8100e2.
Report an issue: GitHub.