zylon-ai/private-gpt · error · ValueError

Provide SKILL.md either in files or skill_md

Error message

Provide SKILL.md either in files or skill_md

What it means

Raised by a Pydantic model_validator on CreateSkillBody when creating a skill: the request must include SKILL.md content either inline via the skill_md field or as a file entry with path == 'SKILL.md' in the files list. The server rejects the request body before it reaches the skill store. It maps to an HTTP 400 from FastAPI's RequestValidationError handling.

Solutions

  1. Add an inline SKILL.md: set skill_md to the full markdown content in the request body.
  2. Or append a file entry with path exactly 'SKILL.md' (exact case) plus its content to files.
  3. If uploading a zip, ensure the archive contains SKILL.md at (or under a wrapper directory containing) its root before sending.
  4. Check the client serializer for path normalization that lowercases or renames entries.

Example fix

// before
{"display_title": "my-skill", "files": [{"path": "README.md", "content": "..."}]}
// after
{"display_title": "my-skill", "skill_md": "---\nname: my-skill\n---\n# My skill", "files": [{"path": "README.md", "content": "..."}]}
Defensive patterns

Strategy: validation

Validate before calling

def has_skill_md(body: dict) -> bool:
    return bool(body.get('skill_md')) or any(
        f.get('path') == 'SKILL.md' for f in body.get('files', [])
    )

assert has_skill_md(payload), 'attach SKILL.md via skill_md or files[]'

Type guard

type SkillPayload = { skill_md?: string; files?: { path: string; content?: string }[] };
const isCreatable = (p: SkillPayload): boolean =>
  Boolean(p.skill_md) || (p.files ?? []).some((f) => f.path === 'SKILL.md');

Try / catch

try {
  await client.post('/skills', payload);
} catch (e: any) {
  if (e?.status === 400 && /SKILL\.md/.test(e.detail)) {
    payload.skill_md = buildDefaultSkillMd();
    await client.post('/skills', payload);
  } else throw e;
}

Prevention

When it happens

Trigger: POST to the skills creation endpoint with neither skill_md set nor any files[] entry whose path is exactly 'SKILL.md' (e.g. {"files": [{"path": "README.md", ...}]} or an entirely empty body). Note the check is case-sensitive: 'skill.md' in files does not satisfy it.

Common situations: Clients uploading only supporting files and forgetting the manifest; typos in the path casing ('Skill.md', 'skill.md'); building the request from a zip whose SKILL.md was flattened or renamed; API version changes that moved SKILL.md from a dedicated field into the files array.

Related errors


AI-assisted analysis of zylon-ai/private-gpt@4a030776a3 (2026-08-15). Data as JSON: /api/errors/84d5331218557329. Report an issue: GitHub.

Appendix: source

Thrown at private_gpt/server/skills/skill_models.py:74

        description="Instruction loading strategy.",
    )
    readonly: bool = Field(
        default=False, description="Readonly flag for protected skills."
    )
    skill_md: str | None = Field(
        default=None,
        description="Inline SKILL.md content (optional when files includes SKILL.md).",
    )
    files: list[SkillFileInput] = Field(
        default_factory=list,
        description="Optional uploaded files for this skill version.",
    )

    @model_validator(mode="after")
    def validate_skill_md_presence(self) -> "CreateSkillBody":
        has_skill_file = any(file.path == "SKILL.md" for file in self.files)
        if not has_skill_file and not self.skill_md:
            raise ValueError("Provide SKILL.md either in files or skill_md")
        return self


class SkillResponse(BaseModel):
    """Serialized skill object returned by skills endpoints."""

    id: str = Field(description="Unique skill identifier.")
    created_at: datetime = Field(description="Creation timestamp.")
    display_title: str = Field(description="Human display title.")
    latest_version: str | None = Field(
        default=None,
        description="Latest version token for this skill.",
    )
    source: Literal["custom", "anthropic", "zylon"] = Field(
        description="Source of the skill."
    )
    type: Literal["skill"] = Field(default="skill", description="Object type.")
    updated_at: datetime = Field(description="Update timestamp.")

View on GitHub (pinned to 4a030776a3)