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

  1. Replace the symlinked skill folder with real files (cp -rL to dereference), or move the actual folder into the skills tree.
  2. If a single file inside the folder is a symlink, copy the target file in place.
  3. Ensure SKILL.md is a regular file (not a symlink, not a directory/pipe).
  4. 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

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


AI-assisted analysis of n8n-io/n8n@5ac6606e81 (2026-08-12). Data as JSON: /api/errors/4468fe477a8100e2. Report an issue: GitHub.