Documentation
¶
Overview ¶
Package agentskill loads, validates and renders the Agent Skills format for Go agents over Open Responses, read through fs.FS so a skill can live in a directory, an embedded bundle, an archive or an adapter over a remote store, with the skill tool through which the model reads a skill's body and files.
A Skill is one skill directory, loaded byte for byte: the parsed frontmatter, the Markdown body and the tree of resource files. Load reads one from any fs.FS and [Validate] reports the specification's rules with the same verdicts as the reference skills-ref validator. Discover loads every skill under an ordered list of Source values into a Catalog, whose Catalog.Prompt renders the available_skills block a product puts in its instructions and whose Catalog.Tool serves the body and the files to the model.
The package depends on openresponses, agenttool, one YAML parser and the standard library. The agent loop is never imported; a product wires the rendered prompt and the tool into its configuration. The AGENTS.md convention, loaded up front rather than on use, is the separate agentsmd module.
Index ¶
Constants ¶
const ( MaxNameLength = 64 MaxDescriptionLength = 1024 MaxCompatibilityLength = 500 // MaxBodyLines is the length the specification advises a SKILL.md // to stay under. A longer body is a warning. MaxBodyLines = 500 )
The specification's limits.
const DefaultMaxBytes = 1 << 20
DefaultMaxBytes is the size above which the tool refuses a file.
const RecordNS = "agentskill:read"
RecordNS is the namespace of the session entry a recorder writes for a skill read, the value Read.RecordNS returns.
const ToolName = "skill"
ToolName is the name of the tool Catalog.Tool returns.
Variables ¶
var ( // ErrNoFrontmatter is returned when the file does not begin with // the opening fence. ErrNoFrontmatter = errors.New("SKILL.md must start with YAML frontmatter (---)") // ErrUnclosedFrontmatter is returned when the closing fence is // missing. ErrUnclosedFrontmatter = errors.New("SKILL.md frontmatter not properly closed with ---") // ErrNotMapping is returned when the frontmatter is valid YAML but // not a mapping. ErrNotMapping = errors.New("SKILL.md frontmatter must be a YAML mapping") )
Errors from Parse. They are about the frontmatter fence and the YAML alone; a value of the wrong shape loads and is reported by Skill.Validate.
var ErrNoSkillFile = errors.New("Missing required file: SKILL.md")
ErrNoSkillFile is returned by Load when the root of the file system holds no SKILL.md.
var ErrOutside = errors.New("path resolves outside the directory")
ErrOutside is the error of a Dir file whose path resolves, through a symlink, to a place outside the directory.
var ErrSkillChanged = errors.New("the skill file changed since the skills were discovered")
ErrSkillChanged is returned by Skill.Instructions, and so by the skill tool, when the skill file is no longer one the skill can be served from: it is gone, it is now spelled otherwise, or its frontmatter no longer parses, so there is no body to separate from it. The product discovers the skills again. Changed frontmatter that parses is not this error; see Skill.Instructions.
Functions ¶
func Encode ¶
Encode writes the skill back as a SKILL.md: the frontmatter with the specification's keys in specification order, Extra keys after them in sorted order, then the body verbatim. Parse(Encode(s)) yields s. Optional fields that are empty are omitted, as is a value that failed to decode into its field, since the Skill never held it.
Types ¶
type Catalog ¶
type Catalog struct {
// Skills holds the skills the prompt lists, in source order and
// then directory-name order. A skill with problems is still here;
// a product that wants only valid skills filters on Problems.
Skills []*Skill
// Shadowed holds skills whose name an earlier source had already
// claimed, kept so a product can report them. Each one's
// [Skill.ShadowedBy] names the skill that holds the name.
Shadowed []*Skill
// Problems maps a skill's location to what [Skill.Validate]
// reported, or to the one error that stopped it loading. A
// directory skipped for a skill file misnamed in case alone has its
// [Warning] under that file's location.
Problems map[string][]Problem
}
Catalog is the skills found by Discover: the winners in source order, the duplicates they shadowed, and every problem found on the way.
func Discover ¶
Discover loads every skill under the sources: each direct child directory holding a SKILL.md is one skill. Sources are searched in order and the first skill with a given name wins, as PATH resolves a command, so a caller lists the most specific source first. A later skill of a taken name is shadowed, unless its source has a Source.Qualifier, in which case it is listed under the qualified name, and shadowed only if that is taken too. A child without a SKILL.md is not a skill and is skipped; one holding a file named SKILL.md in another case, such as Skill.md, is skipped on every host with a Warning in Problems under that file's location.
A skill Catalog.Listed will not offer, for want of a name or a description or for a colon in its name, is kept in Skills but claims no name, so it never shadows a skill that could be offered.
A skill that fails to load is not fatal: the error goes into Problems under its location and discovery continues. A source whose FS cannot be read at its root is an error, because a missing local directory should be skipped by the caller on Dir's error, not silently here. So is a qualifier holding whitespace or a control character, and so are two sources with one Location, a trailing slash aside, whose skills of one directory name would share a location in the prompt and a key in Problems.
func DiscoverDirs ¶
DiscoverDirs is Discover over Dir for each path. A path that is not a directory is an error.
func (*Catalog) Listed ¶ added in v0.0.3
Listed returns the skills the model is offered, in catalog order: the winners that have both a name to call them by and a description to choose them from. A skill missing either cannot be used, since the name is the only handle the tool takes and the description is the only thing the model chooses on, so it is left out of Catalog.Prompt, Catalog.Names, Catalog.Lookup and the tool, as the client implementation guide says to skip it and as the reference's to_prompt refuses to render it.
A skill whose name holds a colon is left out too, where the reference would render it: the specification never allows one, and listed it would claim the "<qualifier>:<name>" a Source.Qualifier gives another skill, so that the model reached the wrong one.
It stays in Skills and its problems stay in Problems, so a product can still list a broken skill and say what is wrong with it, which is why they are loaded at all.
func (*Catalog) Lookup ¶
Lookup returns the skill listed as name, its Skill.ListedName, among the skills Catalog.Listed offers, so what the model can be served is what the prompt lists.
func (*Catalog) Names ¶
Names returns the listed names of the skills Catalog.Listed offers, in catalog order.
func (*Catalog) Prompt ¶
Prompt renders the available_skills block in the exact shape of the reference library's to_prompt: one skill entry per skill in catalog order, its listed name, description and location each on their own line between their tags, with the name and description HTML-escaped. For local sources without a Source.Qualifier the output is byte for byte what skills-ref to-prompt prints for the same directories; the reference has no qualified names. An empty catalog renders the empty block.
Only the skills Catalog.Listed offers are rendered: an entry the model cannot call, because the skill has no name, or choose, because it has no description, would cost tokens on every turn and offer a pointer that goes nowhere. The reference refuses to render the whole block over such a set; this renders the rest.
func (*Catalog) Tool ¶
func (c *Catalog) Tool(opts ...ToolOption) agenttool.Tool
Tool returns an agenttool.Tool named "skill". Called with a name alone it returns the skill's body followed by the list of its files, so the model learns both what to do and what there is to read. Called with a name and a path it returns that file: text as text, an image as an image part, anything else refused with its size and detected type. An unknown skill is an error listing the names the model could have used; an unknown path is an error listing the files. A path naming the skill file itself, "SKILL.md" in any case, is served as the instructions, not as a file: models know a skill's instructions as SKILL.md and ask for it by path, and a host that grants allowed-tools on a read of the instructions must see that read as one, while the frontmatter stays out of the reply.
Everything the tool returns is bytes from the source, framed, never transformed, so the transcript records exactly what the model read. The instructions are read from the skill file at the call, as the files are, so a reply never puts a body from discovery beside a file list from now. A skill whose frontmatter changed since discovery is served its new body under the frontmatter as loaded, and the Read says so; one whose skill file is gone, renamed or unparseable is refused with ErrSkillChanged.
The tool serves the skills Catalog.Listed offers, which are the ones Catalog.Prompt renders, so the model can fetch everything it is shown and nothing it is not.
Every successful call sets a Read as the result's Details, naming the skill, the SKILL.md behind the name and a digest of the bytes served. It implements agenttool.Recordable, so a recorder writes it under RecordNS; the model never sees it.
Built with WithFileClaim, the tool claims the file each call would serve, so a product's policy can decide the read before it happens.
func (*Catalog) Usage ¶
Usage is one paragraph telling the model how to use the skills the prompt lists through the skill tool. A product appends it to Catalog.Prompt when it offers Catalog.Tool.
type Problem ¶
type Problem struct {
Severity Severity
// Field is the frontmatter key the problem is about, "body" for
// the Markdown, or "" for the skill as a whole.
Field string
// Message is the reference validator's wording where it has one.
Message string
}
Problem is one thing Skill.Validate found.
type Read ¶ added in v0.0.3
type Read struct {
// Name is the skill's name, as the model wrote it.
Name string `json:"name"`
// Location is the skill's Location: the SKILL.md that was read.
Location string `json:"location"`
// Path is the file inside the skill that was served, "" for a read
// of the skill's own instructions. A call whose path names the
// skill file, "SKILL.md" in any case, is a read of the
// instructions and is recorded with "".
Path string `json:"path,omitempty"`
// Bytes is the number of bytes served.
Bytes int `json:"bytes"`
// SHA256 is the hex digest of the bytes served: for a read of the
// instructions the text of the reply, which [Skill.Instructions]
// returns, and for a file its own bytes, before an image is base64
// encoded into its part.
SHA256 string `json:"sha256"`
// FrontmatterSHA256 is [Skill.FrontmatterSHA256]: the digest of the
// frontmatter the catalogue was built from, whose allowed-tools a
// host grants on a read of the instructions. SHA256 says what the
// model read and this says what it was granted, so an approval
// bound to both catches a rewrite of either.
FrontmatterSHA256 string `json:"frontmatter_sha256,omitempty"`
// FrontmatterChanged is set when the skill file on disk holds other
// frontmatter than the catalogue was built from. The body served is
// the one on disk, under the frontmatter as loaded: the listing and
// any grant stay the old ones until the product discovers again.
FrontmatterChanged bool `json:"frontmatter_changed,omitempty"`
}
Read is what the skill tool did, set as the Result.Details of every successful call. It implements agenttool.Recordable, so a recorder that knows nothing about skills writes it beside the call as a namespaced JSON entry, and the session can then say which SKILL.md was served rather than only which name the model wrote.
A name is not an identity: it is what a PATH-style search across several sources resolved to at discovery time. Location says which skill won, and SHA256 says what its bytes were, so a replay that serves different bytes for the same name is detectable.
type Skill ¶
type Skill struct {
// FS is the skill's own tree, rooted at its directory: a fs.Sub of
// the source. SKILL.md is at its root. It is nil for a skill built
// by [Parse].
FS fs.FS
// Location is what the prompt shows: the source location joined
// with the directory name and SKILL.md.
Location string
// DirName is the directory's base name, which Name must match. It
// is empty when the skill was not loaded from a directory, and the
// match is then not checked.
DirName string
// Name is the frontmatter name.
Name string
// Description is the frontmatter description.
Description string
// License is the frontmatter license, if any.
License string
// Compatibility is the frontmatter compatibility, if any.
Compatibility string
// Metadata is the frontmatter metadata map, if any.
Metadata map[string]string
// AllowedTools is the frontmatter allowed-tools string, if any.
// [Skill.Rules] parses it.
AllowedTools string
// Extra holds frontmatter keys the specification does not define,
// decoded but not interpreted, so a newer skill loads in an older
// reader and re-encodes without loss. Each is a validation error,
// as the reference validator rejects them.
Extra map[string]any
// Body is the Markdown after the frontmatter, verbatim, as it was
// when the skill was loaded. [Skill.Instructions], which the skill
// tool serves, reads the skill file again.
Body string
// Qualifier is the source's [Source.Qualifier] when [Discover]
// listed the skill under a qualified name because an earlier source
// held its own, and empty otherwise. [Skill.ListedName] joins it
// with Name. It is the product's, not the author's, so validation
// never reads it.
Qualifier string
// ShadowedBy is the Location of the skill that holds the name this
// one wanted, which may be a qualified name when Qualifier was
// cleared. [Discover] sets it on each skill in [Catalog.Shadowed]
// and leaves it empty otherwise.
ShadowedBy string
// contains filtered or unexported fields
}
Skill is one skill directory, loaded. The frontmatter fields carry the specification's keys; Name and Description are trimmed of surrounding space as the reference reader trims them, every other value is kept as written.
func Load ¶
Load reads the skill at the root of fsys: its SKILL.md, parsed with Parse, attached to fsys so Skill.Files and Skill.Open reach the resource files. location names the skill directory for the prompt; the skill's Location is it joined with the name of the skill file, and its DirName is its last element, so a name that does not match the directory is reported. An empty location leaves DirName empty and the match unchecked.
A malformed SKILL.md fails to load. A well-formed one with a bad name loads, and Skill.Validate says what is wrong, so a product can list a broken skill and say why rather than make it vanish.
func LoadDir ¶
LoadDir is Load over the directory, with the absolute, symlink resolved path as the location and the Dir guard on its files.
func Parse ¶
Parse reads the frontmatter and the body of a SKILL.md from src. The result has no FS, Location or DirName; Load fills those in. Parse fails only on the fence and the YAML: a value of the wrong shape, such as a name that is a list, loads with an empty field and is reported by Skill.Validate.
A key written twice is one of the YAML failures, as it is for the reference reader's strictyaml, rather than a problem to report: the file says two things with equal authority and there is no correct value to load. A reviewer's eye stops at the first line, and the model would be told the second.
The fence is line based: the first line must be "---" and the frontmatter runs to the next line that is "---". The body is everything after that line, verbatim. Scalars are read as their text, so a name written as a number is the number's text, as the reference reader does.
func (*Skill) Files ¶
Files lists every file in the skill's tree except the skill file itself, as fs paths relative to the skill's root, sorted. A skill with no FS has no files.
A symlink is listed only when it stats as a file, so the list holds only what Skill.Open can serve. A link the source refuses, as Dir refuses one leading outside, a dangling link and a link to a directory, which the walk does not enter, are left out.
func (*Skill) FrontmatterSHA256 ¶ added in v0.0.10
FrontmatterSHA256 returns the hex SHA-256 of the skill file's frontmatter as Load read it, the bytes between the fences: what the skill's fields, its allowed-tools among them, were parsed from. It is "" for a skill Load did not build. It does not change when the file on disk does; a product that approves a skill's grant binds the approval to it, and a rewrite of allowed-tools reaches a host only through a new discovery, under a new digest.
func (*Skill) Instructions ¶ added in v0.0.10
Instructions returns the text the skill tool serves for a read of the skill's instructions: the Markdown body followed by the list of the skill's files with their sizes. Read.SHA256 for such a read is the hex SHA-256 of this text, so a product that binds an approval to what the model reads computes the digest the session will record:
text, err := s.Instructions() sum := sha256.Sum256([]byte(text)) digest := hex.EncodeToString(sum[:])
The text does not cover the frontmatter, so it says nothing of what allowed-tools grants; bind an approval to Skill.FrontmatterSHA256 as well.
The text is read now, not at discovery: a skill Load read has its skill file read again, and the body is the body on disk. When the frontmatter on disk differs from the one loaded, the body is still served: the skill's fields, its listing and any grant made from its allowed-tools stay those loaded, which is what was approved, until the product discovers again. When the file is gone, renamed or no longer parses, Instructions returns an error wrapping ErrSkillChanged. A skill Load did not build serves Skill.Body.
The file list carries sizes, so the digest changes when any of the skill's files is added, removed or resized: the model reads those too.
func (*Skill) ListedName ¶ added in v0.0.6
ListedName is the name the catalog lists the skill under, which the model calls the skill tool with: Name, or "<Qualifier>:<Name>" when Discover qualified it.
func (*Skill) Open ¶
Open opens a file in the skill's tree. name must satisfy fs.ValidPath, which refuses "..", absolute paths and empty elements, so nothing outside the tree can be named.
func (*Skill) Rules ¶
Rules parses AllowedTools into one ToolRule per token. Tokens are separated by ASCII whitespace (space, tab, line feed, vertical tab, form feed, carriage return) outside parentheses, so a specifier may hold spaces; unbalanced parentheses are an error, because the token cannot then be delimited. An empty field yields no rules and no error.
A token that opens a specifier and supplies none, "Bash()", or whose specifier is the bare carve-out "Bash(!)", is an error rather than a rule, as agentpolicy's parser of the same grammar refuses both. An empty specifier would otherwise be indistinguishable from the bare token "Bash", which grants every call of the tool, so the narrowest thing a skill can write would arrive as the widest grant there is.
func (*Skill) Validate ¶
Validate checks the skill against the specification and returns one Problem per rule broken, in the reference validator's order and with its wording: unknown frontmatter keys, then name, description and compatibility. Rules the reference validator does not check follow: metadata values that are not strings, allowed-tools tokens that do not parse, and a body over MaxBodyLines lines, which is a Warning. A nil result means the skill is valid.
The verdicts and their wording are the reference validator's over the frontmatter both readers parse. Three classes of input fall outside that: Unicode normalisation, the YAML dialect, and an empty allowed-tools specifier, which Skill.Rules refuses and the reference accepts. The README names all three.
Name length is counted in characters, not bytes. The directory match is a plain comparison; the reference validator normalises both sides to NFKC first, which the standard library cannot do, so a name and directory that differ only in Unicode normalisation pass there and fail here.
type Source ¶
type Source struct {
// FS holds skill directories as its direct children, each with a
// SKILL.md. os.DirFS, embed.FS, a zip, or an adapter over a remote
// store.
FS fs.FS
// Location names the source for the model, such as an absolute
// directory, a URL or "mcp://docs". A skill's location is this
// joined with the skill's directory name and SKILL.md. Two sources
// given to one [Discover] must not share it.
Location string
// Qualifier, when set, names the source for the model the way a
// nested directory or a plugin is named, such as "apps/web" or
// "my-plugin". A skill of this source whose name an earlier source
// already claimed is listed as "<Qualifier>:<name>" rather than
// shadowed; a skill whose name is free is listed under its name,
// and a second skill of one name within this source is shadowed.
// Without it a taken name is shadowed. It may not hold whitespace
// or control characters.
Qualifier string
}
Source is where skills come from: a file system and the name the prompt shows for it.
func Dir ¶
Dir is the Source for a local directory: os.DirFS over the directory, with its absolute, symlink resolved path as the location, so the prompt shows what skills-ref shows. The directory must exist.
Dir adds the one guard os.DirFS lacks: a path that resolves through a symlink to somewhere outside the directory is refused with ErrOutside, because a local skill tree may be user-installed and a link to /etc inside it must not be readable through the tool. That includes a skill directory that is itself a link elsewhere; add the link's target as its own Dir source instead.
type ToolOption ¶
type ToolOption func(*toolOptions)
ToolOption configures Catalog.Tool.
func WithFileClaim ¶ added in v0.0.12
func WithFileClaim(fn func(ctx context.Context, s *Skill, file string) ([]agenttool.FactCall, error)) ToolOption
WithFileClaim has the skill tool make the facts claim (agenttool.Factual) for each call, naming the file the call would serve, so a product's policy can decide the read before it happens: hold it to the rules of the product's own read tool, say, where a skills directory is a link into the workspace and a skill's file is the workspace's .env.
The claim is the call itself followed by the calls fn returns for that file. file is a path inside s.FS: for a read of the skill's instructions, by no path or by "SKILL.md" in any case, the skill file as Load read it, and otherwise the path the call names. The claim and the call resolve the skill and the path in one place, so they cannot name different files. The list of files that follows the instructions is not claimed: it names files and serves none.
A call the tool will refuse without reading anything, an unknown skill or a path that is not a relative path inside the skill, claims the call itself alone, and fn is not called. So does a read of the instructions of a skill Load did not build, which serves the body it holds and reads no file. An error from fn is the claim's error, which a policy reads as a call it cannot decide.
Without this option, or with a nil fn, the tool makes no claim.
func WithMaxBytes ¶
func WithMaxBytes(n int64) ToolOption
WithMaxBytes sets the largest file the tool returns; a larger one is refused with its size. There is no partial read, because a half file is a worse instruction than none. The default is DefaultMaxBytes.
type ToolRule ¶
type ToolRule struct {
Tool string
// Spec is the text inside the parentheses, "" when the token has
// no parentheses at all. A token whose parentheses are empty does
// not parse; see [Skill.Rules].
Spec string
}
ToolRule is one token of allowed-tools: a tool name, or a name with a specifier in parentheses, "Bash(git:*)". The specifier's syntax belongs to the product; the rule keeps it as a string, spaces and nested parentheses included, as in "Bash(git add *)" and "Edit(./Finance (2024)/**)".
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
agentskill
command
Command agentskill mirrors the skills-ref CLI: validate a skill directory, print its frontmatter as JSON, or render the available_skills block for one or more skill directories.
|
Command agentskill mirrors the skills-ref CLI: validate a skill directory, print its frontmatter as JSON, or render the available_skills block for one or more skill directories. |