Skip to content

Skills (kapelle.controller.command, command: "skills")

Backlog task 498cfd9c (design doc §15 "Skills and MCP servers"). Reuses the same channel docs/memory-commands.md documents for /memory/ /team//budget -- plain core NATS request/reply, kapelle.controller.command, one queue-group subscriber on the controller side (kapelle_controller.commands.ControllerCommandServer) -- so both the worker's skill_propose MCP tool and the gateway's /skills surface commands share one implementation (kapelle_controller.skills.store.SkillStore).

See docs/mcp.md for the sibling command: "mcp" channel (backlog task d6b93b0c, design doc §15 "MCP servers") -- team-stored external MCP servers reuse this exact same wire pattern (worker/gateway dual caller, source-based authorization skip), just for McpServerStore instead of SkillStore.

What a skill is

A skill is a directory with a SKILL.md and, optionally, scripts/, references/ and assets/, in the AgentSkills format the OpenHands SDK already loads (openhands.sdk.skills):

release-service/
  SKILL.md          # frontmatter: name, description, triggers, allowed_tools, version
  scripts/bump.py   # runnable helpers, invoked by the agent through its normal tools
  references/       # longer material the agent reads on demand

SKILL.md frontmatter carries name, description, version, optional triggers (keywords that activate the skill when a task mentions them), allowed_tools and compatibility. The body is the procedure.

Where skills come from

Source Location Who maintains it Scope
Platform roles/skills/<name>/ in this repo, shipped in the agent image Kapelle maintainers Every team; role-filtered by roles/<role>.md's skills_exclude
Template skills: list in teams/templates/<template>.yaml, pointing at platform skills or a git+...#path pinned spec Whoever owns the template Every team created from the template
Repository .kapelle/skills/<name>/ in the team's repository, read from the default branch (never the agent's own working branch) The team, through ordinary PRs That repository
Team Stored by the controller (this document), through /skills Team members One team, across its repositories

Skills merge by name in that order (kapelle_worker.agent.skills. merge_all_sources), later sources overriding earlier ones -- a repository can specialise a platform skill, and a team can pin its own version without forking the template.

Team skills: two kinds of caller

  • Worker-originated (an agent's skill_propose MCP tool call): sets source explicitly (e.g. "agent:payments/coder") and omits user. The controller skips its team-membership check when source is already set -- the MCP server's own per-agent bearer-token auth already established which team this call is scoped to.
  • Person-originated (a /skills ... surface command, kapelle_gateway.core.GatewayCore._handle_controller_command): omits source, sets user the same way /team//memory do. The controller checks team membership (_MEMBER_OR_ABOVE, same bar as /memory) and derives source = "person:<person_id>" itself.

Request

{
  "command": "skills",
  "team": "payments",
  "args": ["propose", "run-tests", "1.0.0", "# run-tests\n..."],
  "source": "agent:payments/coder"
}

args[0] is the operation; the rest are its positional arguments.

args Operation
["show", name] (alias: ["get", name]) SkillStore.get
["list"] SkillStore.list_all -- every team skill, curated first then proposed, name ascending within each group. entries are SkillEntry.render()'d human-readable blocks.
["list_curated"] Curated skills only, entries are JSON-encoded {"name", "version", "body", "resources"} objects (the real SKILL.md body, not a rendered block) -- how kapelle_worker.agent.skills.load_team_skills resolves the team source at run start, cached briefly per team.
["propose", name, version, body] SkillStore.propose -- upserts the team's one row for name (a re-proposal of an existing name updates that row rather than creating a duplicate), always leaves it proposed. body is a full SKILL.md file (frontmatter + procedure). The worker's skill_propose(name, body) MCP tool extracts version from the frontmatter itself (defaulting to "0.0.0" if absent) before calling this.
["accept", name] SkillStore.accept -- idempotent.
["forget", name] SkillStore.forget -- hard delete.

Only list/show/accept/forget are exposed as /skills gateway commands (design doc §15's own scope for the person-facing surface); propose is reached only through the worker's skill_propose MCP tool in normal operation, though the controller command itself doesn't distinguish the caller beyond the authorization check above.

Response

Same shape as /memory's:

{"ok": true, "text": "1 skill(s)", "entries": ["[curated] run-tests@1.0.0 (from person:1)\n..."]}
  • text: always present on success, a one-line human-readable summary.
  • entries: present only for list -- each element is one SkillEntry.render()'d block ("[status] name@version (from source)\nbody"), ready to show a model or a person as-is.
  • error: chat-ready, same convention as /team//memory's errors.

Safety

Every propose body (and every resource file, when a team skill carries any) is scanned (kapelle_controller.memory.scan.scan_entry_body, reused as-is) for credential-looking patterns and links to hosts outside a small allowlist before it's stored -- a rejection comes back as a normal ok: false reply, error naming what tripped it. Repository skills are loaded only from the default branch by construction (kapelle_worker.agent.skills.load_repository_skills), and a skill's triggers can never widen a role's allowed_tools beyond what the role already has.

A proposed team skill that nobody accepts expires after 30 days, same convention as team memory (kapelle_controller.skills.expiry_loop).

Attribution

Every skill actually injected into a run -- from any of the four sources -- is recorded on the activity feed and the outcome's skills artifact as name@version, so a run can be traced back to the exact procedure it followed (design doc §15 "Versioned and attributed").