Astrolabe / ajxpublic /korvus
Repository

Skill Catalog and Frontmatter Parsing

reference scope internal/skill Verification failed
  • internal/skill/skill.go
  • internal/skill/catalog.go
  • Skill
  • Frontmatter
  • Parse
  • Load
Relevant source files · 10

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:

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.