odysseus-dev/odysseus · warning · HTTPException

markdown is required

Error message

markdown is required

What it means

400 from POST /api/skills/{skill_id}/markdown: the request body's 'markdown' field is missing, not a string, or blank after strip(). The endpoint intentionally rejects empty saves so a stray empty POST cannot wipe an existing SKILL.md.

Source

Thrown at routes/skills_routes.py:1574

        job = _skill_audit_jobs.get((user or "",))
        if job:
            job["cancel"] = True
            job["status"] = "cancelled"
            job["current"] = None
            task = job.get("task")
            if task and not task.done():
                task.cancel()
        return {"ok": True, "status": "cancelled" if job else "none"}

    @router.post("/{skill_id}/markdown")
    async def save_skill_markdown(request: Request, skill_id: str):
        """Replace SKILL.md with new raw content. Parses + validates first."""
        from services.memory.skill_format import Skill
        user = _owner(request)
        body = await request.json()
        new_content = body.get("markdown")
        if not isinstance(new_content, str) or not new_content.strip():
            raise HTTPException(400, "markdown is required")
        skills = skills_manager.load(owner=user)
        match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None)
        if not match:
            raise HTTPException(404, "Skill not found")
        _verify_owner(match, user)
        try:
            sk = Skill.from_markdown(new_content)
        except Exception as e:
            raise HTTPException(400, f"Could not parse SKILL.md: {e}")
        # Never rename on save: a changed `name` in the markdown would move
        # the skill dir (update_skill) and orphan the original id, so a later
        # delete 404s (#1333). Pin to the stored name, like _apply_skill_md.
        sk.name = match.get("name")
        if not sk.owner:
            sk.owner = match.get("owner") or user
        ok = skills_manager.update_skill(match.get("name"), {
            "name": sk.name,
            "description": sk.description,

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Ensure the JSON body contains a non-empty string under the exact key 'markdown'.
  2. Trim client-side and refuse to send whitespace-only content — deletion is what DELETE /{skill_id} is for.
  3. If clearing source is intended, use the delete route instead of an empty markdown save.
  4. Check Content-Type is application/json so FastAPI parses the body at all.

Example fix

# before
client.post(f"/api/skills/{sid}/markdown", json={"markdown": ""})  # 400

# after
content = editor_text.strip()
if not content:
    raise ValueError("refusing to save empty SKILL.md")
client.post(f"/api/skills/{sid}/markdown", json={"markdown": content})
Defensive patterns

Strategy: validation

Validate before calling

content = payload.get("markdown")
assert isinstance(content, str) and content.strip(), "markdown must be a non-empty string"

Type guard

def is_valid_markdown_body(body: dict) -> bool:
    md = body.get("markdown")
    return isinstance(md, str) and bool(md.strip())

Prevention

When it happens

Trigger: POST with body {} or {"markdown": null}; markdown sent as an object/array instead of a string; markdown of only whitespace/newlines; client bug sending the field under a different key ('content', 'md').

Common situations: Saving from an editor whose text state was never initialized; form serialization dropping empty-looking fields; whitespace-only content from a template placeholder that was never filled in.

Related errors


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/b079cf30c5680ad4. Report an issue: GitHub.