TL;DR:
~/.cowork/skills/{name}/SKILL.md파일 하나로 에이전트 능력을 확장하는 플러그인 시스템이며, UI에서 CRUD 즉시 반영된다.
Table of contents
Open Table of contents
스킬이란
스킬은 에이전트의 능력을 확장하는 플러그인이다. 예를 들어 “django-expert” 스킬을 추가하면 에이전트가 Django 프로젝트에 특화된 행동을 한다. Agent Skills Specification 커뮤니티 표준을 참고하여 설계했다.
~/.cowork/skills/
+-- django-expert/
| +-- SKILL.md
+-- code-reviewer/
| +-- SKILL.md
+-- test-writer/
+-- SKILL.md
SKILL.md 형식
---
name: django-expert
description: Django 프로젝트 전문가
license: MIT
metadata:
category: web
version: "1.0"
allowed-tools: read_file write_file execute
---
# django-expert
## When to Use
- Django 프로젝트에서 모델, 뷰, URL, 시리얼라이저 작업할 때
- Django REST Framework API 설계 시
## Instructions
- settings.py 변경 전 반드시 현재 설정을 먼저 읽으세요
- migration은 항상 makemigrations -> migrate 순서로 실행
- 테스트는 pytest-django 사용
프론트매터 파싱
def _parse_skill_frontmatter(content: str) -> dict:
result = {"name": "", "description": "", "allowed_tools": []}
if not content.startswith("---"):
return result
parts = content.split("---", 2)
if len(parts) < 3:
return result
for line in parts[1].strip().splitlines():
if ":" not in line:
continue
key, _, val = line.partition(":")
key = key.strip()
val = val.strip()
if key == "name":
result["name"] = val
elif key == "description":
result["description"] = val
elif key == "allowed-tools":
result["allowed_tools"] = val.split()
return result
단순한 YAML 파서로 외부 의존성 없이 처리한다.
스킬 해석과 주입
에이전트 빌드 시 _resolve_skills()가 스킬 디렉토리를 탐색한다:
def _resolve_skills(workspace_dir: Path) -> list[str]:
sources: list[str] = []
global_skills = config.WORKSPACE_ROOT / "skills"
if global_skills.is_dir():
sources.append("skills/")
ws_skills = workspace_dir / "skills"
if ws_skills.is_dir():
sources.append("skills/")
return sources
create_deep_agent의 skills 파라미터로 전달되면, SDK가 스킬 디렉토리의 모든 SKILL.md를 읽어 에이전트 컨텍스트에 포함한다. Deep Agents SDK의 SkillsMiddleware가 프로그레시브 디스클로저를 처리한다.
우선순위:
- 글로벌 스킬:
~/.cowork/skills/ - 워크스페이스 스킬:
{workspace}/skills/(오버라이드)
REST API
스킬 목록 조회
@router.get("/settings/skills")
async def list_skills():
skills_dir = config.WORKSPACE_ROOT / "skills"
if not skills_dir.is_dir():
return {"skills": []}
skills = []
for d in sorted(skills_dir.iterdir()):
if not d.is_dir():
continue
skill_file = d / "SKILL.md"
if not skill_file.exists():
continue
content = read_memory_file(skill_file)
meta = _parse_skill_frontmatter(content)
meta["path"] = str(d.relative_to(config.WORKSPACE_ROOT))
meta["content"] = content
skills.append(meta)
return {"skills": skills}
스킬 생성/수정
@router.put("/settings/skills/{skill_name}")
async def update_skill(skill_name: str, req: MemoryUpdateRequest):
safe_name = _validate_skill_name(skill_name)
skill_dir = config.WORKSPACE_ROOT / "skills" / safe_name
if not is_safe_path(config.WORKSPACE_ROOT / "skills", skill_dir.resolve()):
raise HTTPException(403, "접근 거부")
skill_dir.mkdir(parents=True, exist_ok=True)
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(req.content, encoding="utf-8")
await rebuild_all_agents_safe()
return {"ok": True}
스킬 이름 검증
def _validate_skill_name(name: str) -> str:
clean = name.strip()
if not re.match(r'^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]?$', clean):
raise HTTPException(400, "스킬 이름은 영문 소문자, 숫자, 하이픈만 허용")
if ".." in clean or "/" in clean or "\\" in clean:
raise HTTPException(400, "잘못된 스킬 이름")
return clean
경로 탈출 공격을 방지하기 위해 이름 형식을 엄격히 제한한다.
UI: SkillsPanel
export function SkillsPanel() {
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [selectedSkill, setSelectedSkill] = useState<string | null>(null);
const [skillContent, setSkillContent] = useState("");
async function handleCreate() {
const template = `---
name: ${name}
description: 새 스킬 설명을 입력하세요
allowed-tools: read_file write_file execute
---
# ${name}
## When to Use
- ...
## Instructions
...
`;
await fetch(`${base}/settings/skills/${name}`, {
method: "PUT",
body: JSON.stringify({ target: name, content: template }),
});
}
// 스킬 목록 + 선택된 스킬 편집기
return (
<div>
{skills.map((skill) => (
<div onClick={() => setSelectedSkill(skill.name)}>
<span className="font-mono">{skill.name}</span>
<span>{skill.description}</span>
{skill.allowed_tools.map((tool) => <span>{tool}</span>)}
</div>
))}
{selectedSkill && (
<textarea value={skillContent} onChange={...} />
)}
</div>
);
}
프로그레시브 디스클로저
스킬은 “필요할 때만 로드”되는 프로그레시브 디스클로저 패턴을 따른다:
- 에이전트 빌드 시 스킬 디렉토리 존재 여부만 확인
- SDK가 작업 컨텍스트에 맞는 스킬만 활성화
- 사용자가 스킬을 삭제하면 즉시 비활성화
이 방식으로 불필요한 스킬이 LLM 컨텍스트를 낭비하지 않는다.
실측 데이터
| 항목 | 수치 |
|---|---|
| 스킬 로딩 시간 (SKILL.md 파싱) | ~2ms/파일 |
| 스킬 10개 등록 시 시스템 프롬프트 증가량 | ~1,500 토큰 (메타데이터만) |
| 스킬 이름 최대 길이 | 64자 (a-z, 0-9, 하이픈) |
| 스킬 변경 → 에이전트 재빌드 | ~150ms |
| API 응답 시간 (GET /settings/skills) | ~8ms (스킬 5개 기준) |
삽질 노트
Skills 폴더를 ~/.cowork/skills/에 뒀더니 _resolve_skills()에서 워크스페이스 기준 상대경로(skills/)를 반환하는 바람에, SDK가 현재 작업 디렉토리의 skills/ 폴더를 찾으려 해서 스킬이 0개로 나왔다. config.WORKSPACE_ROOT 기준으로 경로를 해석하도록 수정하니 해결됐다. 경로 문제라 에러 메시지도 안 나오고 그냥 조용히 스킬이 무시되어서 디버깅에 시간이 꽤 걸렸다.
두 번째 삽질은 스킬 이름 검증이었다. 초기에는 이름에 /를 허용했는데, PUT /settings/skills/../../etc/passwd 같은 요청으로 경로 탈출이 가능했다. 정규식으로 영문 소문자, 숫자, 하이픈만 허용하도록 바꾸고, 추가로 is_safe_path() 이중 검증을 넣었다.
세 번째로, 스킬 프론트매터 파싱에 pyyaml을 쓰려다가 의존성 추가가 부담스러워 직접 파서를 작성했다. 간단한 key: value 형태만 처리하면 되니 20줄 정도로 충분했고, 중첩 YAML 구조(metadata.category)까지 지원할 필요는 없었다.
자주 묻는 질문
스킬 추가 후 에이전트 재시작이 필요한가?
아니다. rebuild_all_agents_safe()가 스킬 변경 즉시 모든 활성 에이전트를 재빌드한다. 진행 중인 대화의 다음 메시지부터 적용된다.
allowed-tools는 어떤 역할인가?
스킬이 사용할 수 있는 도구 목록을 선언한다. 현재는 메타데이터로만 사용되며, 향후 도구 접근 제어에 활용할 예정이다.