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_proposeMCP tool call): setssourceexplicitly (e.g."agent:payments/coder") and omitsuser. The controller skips its team-membership check whensourceis 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): omitssource, setsuserthe same way/team//memorydo. The controller checks team membership (_MEMBER_OR_ABOVE, same bar as/memory) and derivessource = "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 forlist-- each element is oneSkillEntry.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").