Skip to main content

Agent Skills in Spring AI: Packaging Reusable Capabilities for Your Agents

Package an agent’s know-how as SKILL.md folders and let Spring AI load them on demand with one tool. What the model sees, what a load costs, and the edge cases I reproduced.

An agent that does many jobs needs a lot of instructions: how to write release notes, how to review a SQL migration, how to summarise an incident. Pasting all of that into every prompt is expensive and drowns the task. An agent skill solves it by keeping each set of instructions in its own folder and showing the model only a one-line description until it decides to use one. Spring AI supports this through the spring-ai-agent-utils library. This post builds three skills, runs them through a scripted model, and reports what the model actually sees and what a load costs.
Versions and limits. Spring Boot 4.1.1, Spring AI 2.0.1 (the GA line; this is not a 2.1 milestone feature), org.springaicommunity:spring-ai-agent-utils 0.13.0, JDK 25. The library is a community project, not part of Spring AI itself. Older articles mention 0.4.2; 0.13.0 was the latest on Maven Central when I wrote this. Every test uses a hand-written scripted chat model, so no real LLM was called: I can tell you what is sent to the model, not whether a given model picks the right skill. All code and raw output is in the agent-skills module of my spring-ai repository.

The failure first: three things that do not behave the way you might assume

Before the how-to, here are the surprises, each reproduced in output/03-loading-edge-cases.txt:
== unknown skill name
call Skill(command="does-not-exist") returns: "Skill not found: does-not-exist"

== the same name in two directories (classpath skills first, then the copy)
body served for release-notes contains the first copy ('Breaking')   : true
body served for release-notes contains the second copy               : false

== a SKILL.md with no description
loaded: name=no-description frontMatterKeys=[name]
'no-description' appears in the tool description : true
  • An unknown skill is a string, not an exception. The model gets the text “Skill not found: …” back and has to cope. Your code never sees an error.
  • Duplicate names: the first registered wins, silently. If a team ships its own release-notes after the built-in one, theirs is never served. Nothing is logged that I saw.
  • A skill with no description still loads and is listed by name only. The model then has nothing but the name to decide with, so it will rarely pick it.
And a fourth, which matters for safety. The sql-review skill has allowed-tools: Bash in its front matter, and the library echoes that into the listing as <allowed-tools>Bash</allowed-tools> (verified). But I searched every class in the jar for the string “allowed-tools” and found none that reads the value to restrict anything. I found no enforcement. I did not test it with a live model that has a shell tool, so treat allowed-tools as a label, not a lock.

What a skill is

A skill is a folder with a SKILL.md file. The file starts with a small header holding a name and a description, then the instructions in Markdown. The folder may also hold scripts/, references/ and assets/. This is the convention described in Spring’s January 2026 article on generic agent skills. Here is one of mine, in full:
---
name: incident-summary
description: Summarise an incident timeline into impact, cause, and follow-ups for a status page or postmortem.
---

# Incident summary

State impact first (who, how long), then the proximate cause, then the follow-ups with owners.
Keep the timeline to the five events that changed what responders did.
The other two are release-notes, which points at references/style-guide.md, and sql-review, which points at scripts/lint.sh. All three live under src/main/resources/skills/ in the module.

Wiring it up

Add the dependency next to spring-ai-client-chat:
<dependency>
    <groupId>org.springaicommunity</groupId>
    <artifactId>spring-ai-agent-utils</artifactId>
    <version>0.13.0</version>
</dependency>
Then build the tool and hand it to a ChatClient with the tool-calling advisor (the 2.0 replacement for the old per-model loop, explained in Tool Calling in Spring AI 2.0). This is SkillClients.java, trimmed to the two methods that matter:
public static ToolCallback bundledSkills() {
    return SkillsTool.builder()
        .addSkillsResource(new ClassPathResource("skills"))
        .build();
}

public static ChatClient client(ChatModel model, List<ToolCallback> tools) {
    return ChatClient.builder(model)
        .defaultOptions(ToolCallingChatOptions.builder())
        .defaultToolCallbacks(tools)
        .defaultAdvisors(ToolCallingAdvisor.builder().build())
        .build();
}
Skills in a directory on disk work too, via addSkillsDirectory. I used the classpath because the skills then ship inside the jar.

What the model sees before it loads anything

The library registers a single tool named Skill that takes one argument, command, the skill name. The clever part is the tool’s description: it contains an <available_skills> block listing each skill’s name and description, and nothing else. That is all the model has to choose from. Captured output, 01-skill-listing.txt:
<available_skills>
<skill>
  <name>incident-summary</name>
  <description>Summarise an incident timeline into impact, cause, and follow-ups for a status page or postmortem.</description>
</skill>
<skill>
  <name>release-notes</name>
  ...
</skill>
<skill>
  <name>sql-review</name>
  ...
  <allowed-tools>Bash</allowed-tools>
</skill>
</available_skills>

contains 'release-notes' name        : true
contains a body line ('Breaking')    : false
contains the reference file name     : false
The test asserts that no skill body and no reference file name appears in that description. For three skills the whole description is 1,602 characters.
Model call 1sees names + descriptions only Skill(command=”release-notes”)tool call Tool result = SKILL.md bodyplus the base directory Model call 2follows the body, writes the answer
The diagram is the round trip I captured: two model calls, and the instructions arrive only in the second one, as a tool result.

Loading one skill

With a scripted model that asks for release-notes and then answers, the test counts two model calls and checks that the first call does not contain the skill body. What came back as the tool result (02-skill-load-round-trip.txt):
model calls: 2
first call mentions the skill body ('Breaking' in any message)  : false

Base directory for this skill: <module>/target/classes/skills/release-notes

# Release notes

Turn a list of merged changes into release notes a user can act on.
1. Group the changes under **Breaking**, **Added**, **Fixed** and **Internal**, in that order.
...
3. Read `references/style-guide.md` (in this skill's directory) before writing the final text.
I replaced my machine path with <module>. The first line is useful: the model is told the skill’s base directory so it can resolve references/style-guide.md or scripts/lint.sh. To actually read or run those files the model needs file and shell tools as well; the library has FileSystemTools and ShellTools for that, which I did not exercise.

The cost argument, in characters

The point of the design is that adding a skill costs about one description, not one body. I measured this with synthetic skills whose bodies are 8,000 characters and descriptions about 90 (04-listing-cost.txt):
SkillsTool description (chars)If all bodies were inlinedRatio
31,39924,00017.2× smaller
102,45680,00032.6× smaller
254,766200,00042.0× smaller
508,616400,00046.4× smaller
10016,316800,00049.0× smaller
These are character counts, not tokens; a rough rule of thumb is four characters per token, but that is my estimate and not measured with a tokenizer. The listing still grows linearly with the number of skills, so at 100 skills you are sending about 16,000 characters on every call. Past that point, look at the progressive-disclosure advisor in the tool-calling post.
Everything else the library exposes (from the compiled classes) SkillsTool.builder() has addSkillsDirectory, addSkillsDirectories, addSkillsResource, addSkillsResources, addSkill(name, description, content) for a skill defined in code, workspace, toolDescriptionTemplate and build(). Skills.loadDirectory(String) returns the parsed skills, each with name() and toXml(). The library also ships FileSystemTools and ShellTools. I listed these with javap; I ran only the builder and loader methods shown above.
Run the module yourself
git clone https://ankurm.com/git.app/asmhatre/spring-ai.git
cd spring-ai/agent-skills
./scripts/run-all.sh
JDK 25 and Maven are the only requirements. No API key or network access is needed because the model is scripted. The four tests write the four output files.
Should you even do this? If your agent has one or two jobs, put the instructions in the system prompt; skills add a moving part for no gain. They pay off when you have many distinct procedures that only occasionally apply, or when different teams own different procedures and want to ship them as files. Be aware that the library is a community project at 0.x, that a skill’s script runs with whatever shell tool you give the model, and that I could not check how well a real model chooses between similar descriptions. Write descriptions that start with what the skill does and end with “Use when…”. For the security side of giving a model a shell, see Prompt Injection Defense in Spring AI.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.