Skip to content

Toolnexus.Skill.load

Elixir · package toolnexus · SPEC §3 · elixir/lib/toolnexus/skill.ex

@spec load(String.t() | [String.t()] | keyword() | map()) :: Toolnexus.Skill.Source.t()
def load(input)
# %Toolnexus.Skill.Source{skills: [%Skill.Info{}], tool: %Toolnexus.Tool{}, prompt: String.t()}

Walks a skills root for every **/SKILL.md, parses each file’s YAML frontmatter, and returns three things: the discovered skills, a single tool named skill, and a prompt catalog you paste into your system prompt.

That single-tool design is the whole point. The model sees a short list of skill names and descriptions in the system prompt and one skill tool; the full instruction body and the file listing are only loaded when it actually calls skill(name: "…"). That is progressive disclosure — twenty skills cost you twenty lines of context, not twenty tool schemas.

  • You keep prompt-shaped expertise on disk — a skills/ folder of Markdown playbooks, each with scripts and reference files beside it — and want the model to pull one in on demand.
  • You want to add capability without redeploying — drop a new SKILL.md in the folder and call load/1 again.
  • You are following the open SKILL.md convention used by Claude’s agent skills, so the same folder works with other tooling.

If you only need to inspect what is on disk — including the files that were rejected and why — use Toolnexus.Skill.list/1; it does no tool wiring.

1. Load the shared fixture and read the catalog

Section titled “1. Load the shared fixture and read the catalog”

This is examples/skills, the fixture every port is tested against.

alias Toolnexus.Skill
# TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path.
repo = System.get_env("TOOLNEXUS_REPO") || "."
dir = Path.join([repo, "examples", "skills"])
source = Skill.load(dir)
# One skill discovered, parsed from its YAML frontmatter.
[info] = source.skills
true = info.name == "hello-world"
true = String.starts_with?(info.description, "A tiny example skill.")
true = String.ends_with?(info.location, "hello-world/SKILL.md")
true = info.origin == :fs
# Exactly ONE tool, whatever the skill count.
true = source.tool.name == "skill"
true = source.tool.source == "skill"
true = source.tool.input_schema["required"] == ["name"]
# The catalog goes in your system prompt — names + descriptions only.
true = String.contains?(source.prompt, "## Available Skills")
true = String.contains?(source.prompt, "- **hello-world**:")
# The instruction body is NOT in the prompt. That is the disclosure boundary.
false = String.contains?(source.prompt, "Hello World Skill")
IO.puts("ok: #{info.name} | 1 tool: #{source.tool.name}")

2. Calling the skill tool — what the model actually gets back

Section titled “2. Calling the skill tool — what the model actually gets back”

This is the second half of progressive disclosure: the body, a base directory, and a sampled file listing so the model can go read the resources next to the skill.

alias Toolnexus.{Context, Skill}
repo = System.get_env("TOOLNEXUS_REPO") || "."
source = Skill.load(Path.join([repo, "examples", "skills"]))
res = source.tool.execute.(%{"name" => "hello-world"}, %Context{})
false = res.is_error
out = res.output
true = String.starts_with?(out, ~s(<skill_content name="hello-world">))
true = String.ends_with?(out, "</skill_content>")
# The instruction body, verbatim from below the frontmatter.
true = String.contains?(out, "# Skill: hello-world")
true = String.contains?(out, "1. Read `scripts/greet.sh` in this skill's base directory.")
# An absolute file:// base so relative paths in the skill resolve.
true = String.contains?(out, "Base directory for this skill: file:///")
# A SAMPLED listing of sibling files — SKILL.md itself is excluded.
true = String.contains?(out, "<skill_files>")
true = String.contains?(out, "greet.sh</file>")
false = String.contains?(out, "SKILL.md</file>")
# Metadata carries the on-disk directory for hosts that need a real path.
true = res.metadata.name == "hello-world"
true = String.ends_with?(res.metadata.dir, "skills/hello-world")
IO.puts("ok: #{byte_size(out)} bytes of skill_content")

3. The full surface — several roots, the sample cap, and the miss path

Section titled “3. The full surface — several roots, the sample cap, and the miss path”

:dirs accepts a list; missing roots warn on stderr rather than raising, so one bad path never takes down the others. :sample_limit controls the skill_files listing.

alias Toolnexus.{Context, Skill}
repo = System.get_env("TOOLNEXUS_REPO") || "."
dir = Path.join([repo, "examples", "skills"])
# Several roots at once; a nonexistent one warns and contributes nothing.
merged = Skill.load(dirs: [dir, Path.join([repo, "does", "not", "exist"])])
true = Enum.map(merged.skills, & &1.name) == ["hello-world"]
# A bare binary and a bare list are shorthand for `dirs:`.
true = Enum.map(Skill.load(dir).skills, & &1.name) == ["hello-world"]
true = Enum.map(Skill.load([dir]).skills, & &1.name) == ["hello-world"]
# sample_limit: -1 omits the <skill_files> block entirely (smallest context).
lean = Skill.load(dirs: dir, sample_limit: -1)
lean_out = lean.tool.execute.(%{"name" => "hello-world"}, %Context{}).output
false = String.contains?(lean_out, "<skill_files>")
true = String.contains?(lean_out, "Base directory for this skill:")
# sample_limit: n > 0 caps the listing; 0 (the default) means 10.
capped = Skill.load(dirs: dir, sample_limit: 1)
capped_out = capped.tool.execute.(%{"name" => "hello-world"}, %Context{}).output
true = capped_out |> String.split("<file>") |> length() == 2
# An unknown name is a tool ERROR, not a raise — the model sees what is available.
miss = merged.tool.execute.(%{"name" => "nope"}, %Context{})
true = miss.is_error
true = miss.output == ~s(Skill "nope" not found. Available skills: hello-world)
# With no skills at all the catalog says so, and the tool still exists.
empty = Skill.load(dirs: [])
[] = empty.skills
true = empty.prompt == "No skills are currently available."
true = empty.tool.name == "skill"
IO.puts("ok: #{length(merged.skills)} skill(s), miss handled, sample_limit honoured")

Accepts a binary (one root), a list of binaries (several roots), or a map / keyword list:

Option Default What it does
:dirs [] A root, or list of roots, walked for **/SKILL.md. node_modules and .git are skipped; symlinks are followed with cycle protection.
:skills [] Skills supplied as data maps instead of files — see load with data.
:provider nil A 0-arity function returning skill-data maps, resolved once. A failure is isolated with a warning.
:filter nil name => boolean allowlist / droplist. nil or %{} means all.
:sample_limit 0 Sibling-file cap in skill_files: 0 ⇒ 10, n > 0n, -1 ⇒ omit the block.
Field Type What it is
skills [%Skill.Info{}] name, description, location, content, origin, resources, base.
tool %Toolnexus.Tool{} The single skill tool, source: "skill", one required name argument.
prompt String.t() The catalog to paste into your system prompt — described skills only, sorted by name.