Skill Catalog and Frontmatter Parsing
- internal/skill/skill.go
- internal/skill/catalog.go
- Skill
- Frontmatter
- Parse
- Load
Relevant source files · 10
- internal/skill/skill.go 12–19, 23–26, 28, 31–55
- internal/skill/catalog.go 18–44
- cmd/korvus/main.go 18–19, 156–162
- internal/config/config.go 210–217, 224–257
- internal/session/scratch.go 191–207
- internal/session/skills_test.go 82–124, 129–138
- internal/agent/prompt.go 29–34, 55–61
- internal/agent/agent.go 624–635
- internal/transport/system.go 45–54, 84–98
- internal/skill/skill_test.go 45–81
Korvus ships its investigation know-how as skills: small Markdown documents that tell the agent how to drive a particular backend — which query shape to prefer for a trace lookup, which label set to filter a metric query by. Each skill file is a self-contained unit with a YAML header and a Markdown body, and the internal/skill package turns a directory of those files into the catalog that gets injected into an agent's system prompt.
The package is deliberately tiny — two files, a hundred lines — because the interesting decisions are not in the parsing but in which catalog a given session receives and when it is read. A skill is static content: it is loaded once, it never changes while a session runs, and the same catalog is reused for every model call in that session. That is why the catalog has a load-time filter (a skill that needs an MCP server that never connected is dropped) rather than a runtime check.
How it works
The file format
A skill file is YAML frontmatter delimited by --- lines, followed by Markdown:
---
name: prometheus-metrics
description: Query Prometheus for time-series metrics
tags:
- metrics
mcp_server: prometheus
---
# Prometheus Query Guidance
Prefer range queries for trends.The frontmatter is described by the Frontmatter struct, which carries a name, a description, a tag list and an optional mcp_server field Frontmatter. The mcp_server field is the filter hook: the comment on the struct records that a skill naming a server is excluded unless that server is in the connected set Frontmatter. A skill without the field is unconditional — it is included regardless of which MCP servers are up.
The parsed result is a Skill: the frontmatter plus the Markdown body that will be injected into the system prompt Skill.
Parsing one file
Parse is the whole of the format handling Parse. It trims leading whitespace and newlines from the raw bytes, then requires the file to begin with the --- delimiter Parse. From there it searches for the next line that starts with ---; that mark closes the frontmatter block, and everything after it is the body Parse.
The header is unmarshalled with gopkg.in/yaml.v3 into a Frontmatter value, and a YAML error is wrapped and returned Parse. A header with no name is rejected outright with a "missing required field" error Parse — the name is what the prompt's skill index lists and what the API reports, so a nameless skill would be unreachable.
The body is trimmed of surrounding whitespace before it is stored on the Skill, so the Markdown that ends up in the prompt has no stray leading or trailing blank lines Parse.
Three failure modes are therefore possible: no opening delimiter, an unterminated block, and invalid YAML Parse. All three are errors rather than silent defaults — a malformed skill file is a configuration mistake an operator needs to see, not something to skip quietly.
Loading a directory
Load turns a directory into a catalog Load. It globs *.md in the given path and sorts the resulting paths, which fixes the order skills appear in the prompt so that two runs over the same tree produce the same text Load.
Each file is read and handed to Parse; a read error or a parse error aborts the whole load with the offending filename wrapped into the message Load. One broken file therefore fails the load rather than shrinking the catalog.
The filter comes next. A skill whose mcp_server names a server that is not in connectedServers is dropped with an info log naming the skill and the server it wanted Load. A skill with no mcp_server passes through untouched. The load ends with a log line reporting the path and the number of skills that survived Load.
Where the catalog comes from
The startup path is in the CLI: MCP discovery happens before the catalog is loaded, because the filter needs the set of servers that actually connected startup order. The connected server names are taken straight from the MCP registry and passed to Load alongside the configured skills path the startup catalog load. The skills.path configuration key is documented as a dev-only override — the catalog for sessions pinned to no workspace commit — and it defaults to ./skills Skills Default.
A session pinned to a workspace commit does not use that directory. The session manager reads the skills/ tree that its own commit seeded into the session's scratch directory and loads it with the same Load function, filtered by the same connected-server list Manager::skillsFor. A pinned session that could not read its commit's skills falls back to no skills at all — never to the deployment catalog — because a fallback would build a prompt that the session's workspace_ref does not describe Manager::skillsFor. An unpinned session receives the deployment catalog TestAnUnpinnedSessionGetsTheSkillsPathCatalog, and two sessions pinned to the same commit get identical catalogs even across fresh scratch directories TestSessionsPinnedToDifferentCommitsGetTheirOwnCommitsSkills.
How the catalog reaches the model
The agent turns a catalog into prompt text. systemPrompt writes a skill index — one line per skill with its name and description — and then the full Markdown body of each skill under its own heading systemPrompt the skill index and bodies. The function's own documentation records the intent: it is called once per run and the result does not change mid-session systemPrompt.
Attribution comes back the other way. The agent resolves the loaded skills to the ones a tool call can be credited to — a skill whose mcp_server matches a server that was actually invoked Agent::skillsUsed. Skills without an mcp_server are omitted from that count, since nothing observable ties them to a call.
The catalog is also what the API reports. The system service lists the loaded skills with their name, description, tags and MCP server systemService::ListSkills, and the status RPC reports how many were loaded systemService::GetSystemStatus.
Error handling
| Situation | Behaviour | Location |
|---|---|---|
File does not start with --- |
skill: missing frontmatter delimiter |
Parse |
No closing --- line |
skill: unterminated frontmatter block |
Parse |
| Frontmatter is not valid YAML | wrapped skill: parse frontmatter: … |
Parse |
Frontmatter has no name |
skill: frontmatter is missing required field "name" |
Parse |
| A file in the directory is malformed | whole load fails, filename wrapped into the error | Load |
| Skill needs an MCP server that is not connected | skill excluded, info log naming skill and server | Load |
| A pinned session's commit has no readable skills | session runs with no skills and a warning | Manager::skillsFor |
The filtering behaviour is pinned by a test that writes three skills into a temp directory and asks for a catalog with only prometheus connected: the Loki skill is dropped, the Prometheus and the generic one survive TestLoadFiltersByConnectedServers.
Relationships
flowchart TD
main["cmd/korvus/main.go"] --> Load
Load --> Parse
Parse --> Skill
Parse --> Frontmatter
Load --> Skill
Skill --> Frontmatter
main -->|"cfg.Skills.Path, registry.Servers()"| Load
Load -->|"skills/ tree of the pinned commit"| Manager_skillsFor["Manager::skillsFor"]
Manager_skillsFor --> Load
Manager_skillsFor --> Skill
Skill --> systemPrompt["systemPrompt"]
Skill --> ListSkills["systemService::ListSkills"]
Skill --> skillsUsed["Agent::skillsUsed"]Reference
| Symbol | Kind | What it does | Location |
|---|---|---|---|
Skill |
struct | A loaded skill: parsed frontmatter plus Markdown body | skill.go |
Frontmatter |
struct | The YAML header: name, description, tags, optional mcp_server |
skill.go |
Parse |
function | Splits raw skill bytes into frontmatter and Markdown body | skill.go |
frontmatterDelimiter |
variable | The --- marker that opens and closes the frontmatter block |
skill.go |
Load |
function | Reads *.md from a directory, parses each, and filters by connected MCP servers |
catalog.go |
Generated by astrolabe from commit 0c2dca73 on branch main.