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
- Ensure the JSON body contains a non-empty string under the exact key 'markdown'.
- Trim client-side and refuse to send whitespace-only content — deletion is what DELETE /{skill_id} is for.
- If clearing source is intended, use the delete route instead of an empty markdown save.
- 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
- Block the save button for empty/whitespace editors.
- Use DELETE for removal — never an empty markdown save.
- Send the field under the exact key 'markdown' with Content-Type application/json.
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
- Could not parse SKILL.md: {e}
- query is required
- No documents specified
- Provide at least one point, box, or object text
- Box must be [x1, y1, x2, y2]
AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14).
Data as JSON: /api/errors/b079cf30c5680ad4.
Report an issue: GitHub.