{"record":{"id":"b079cf30c5680ad4","repo":"odysseus-dev/odysseus","slug":"markdown-is-required","errorCode":null,"errorMessage":"markdown is required","messagePattern":"markdown is required","errorType":"http","errorClass":"HTTPException","httpStatus":400,"severity":"warning","filePath":"routes/skills_routes.py","lineNumber":1574,"sourceCode":"        job = _skill_audit_jobs.get((user or \"\",))\n        if job:\n            job[\"cancel\"] = True\n            job[\"status\"] = \"cancelled\"\n            job[\"current\"] = None\n            task = job.get(\"task\")\n            if task and not task.done():\n                task.cancel()\n        return {\"ok\": True, \"status\": \"cancelled\" if job else \"none\"}\n\n    @router.post(\"/{skill_id}/markdown\")\n    async def save_skill_markdown(request: Request, skill_id: str):\n        \"\"\"Replace SKILL.md with new raw content. Parses + validates first.\"\"\"\n        from services.memory.skill_format import Skill\n        user = _owner(request)\n        body = await request.json()\n        new_content = body.get(\"markdown\")\n        if not isinstance(new_content, str) or not new_content.strip():\n            raise HTTPException(400, \"markdown is required\")\n        skills = skills_manager.load(owner=user)\n        match = next((s for s in skills if s.get(\"name\") == skill_id or s.get(\"id\") == skill_id), None)\n        if not match:\n            raise HTTPException(404, \"Skill not found\")\n        _verify_owner(match, user)\n        try:\n            sk = Skill.from_markdown(new_content)\n        except Exception as e:\n            raise HTTPException(400, f\"Could not parse SKILL.md: {e}\")\n        # Never rename on save: a changed `name` in the markdown would move\n        # the skill dir (update_skill) and orphan the original id, so a later\n        # delete 404s (#1333). Pin to the stored name, like _apply_skill_md.\n        sk.name = match.get(\"name\")\n        if not sk.owner:\n            sk.owner = match.get(\"owner\") or user\n        ok = skills_manager.update_skill(match.get(\"name\"), {\n            \"name\": sk.name,\n            \"description\": sk.description,","sourceCodeStart":1556,"sourceCodeEnd":1592,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/routes/skills_routes.py#L1556-L1592","documentation":"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.","triggerScenarios":"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').","commonSituations":"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.","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."],"exampleFix":"# before\nclient.post(f\"/api/skills/{sid}/markdown\", json={\"markdown\": \"\"})  # 400\n\n# after\ncontent = editor_text.strip()\nif not content:\n    raise ValueError(\"refusing to save empty SKILL.md\")\nclient.post(f\"/api/skills/{sid}/markdown\", json={\"markdown\": content})","handlingStrategy":"validation","validationCode":"content = payload.get(\"markdown\")\nassert isinstance(content, str) and content.strip(), \"markdown must be a non-empty string\"","typeGuard":"def is_valid_markdown_body(body: dict) -> bool:\n    md = body.get(\"markdown\")\n    return isinstance(md, str) and bool(md.strip())","tryCatchPattern":null,"preventionTips":["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."],"tags":["skills","http-400","validation","markdown","request-body"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}