Here's a problem every agent builder eventually runs into: your assistant needs to be good at a lot of things. Writing SQL. Composing emails. Debugging code. Maybe more later. So where do all those instructions go?
The obvious answer is: cram them all into the system prompt. But that "obvious" answer causes real problems as your agent grows. In this post, I'll walk through those problems first, then show how the skills pattern in LangChain fixes them, using a small working example.
The problem: one giant prompt
Let's say you want a single assistant that can write SQL, draft emails, and help debug code. The naive approach is one big system prompt:
You are a helpful assistant.
When writing SQL:
- Write queries using only SELECT, INSERT, UPDATE, DELETE
- Always use parameterized queries to prevent SQL injection
- Format SQL keywords in UPPERCASE
- Include brief comments for complex queries
When writing emails:
- Keep emails concise and professional
- Use clear subject lines
- Structure: greeting -> purpose -> details -> call to action -> sign-off
- Adjust tone based on context
When debugging:
- First reproduce the error
- Check logs and error messages
- Isolate the root cause
- Suggest a fix with explanation
...
This works fine for three domains. But keep adding more — legal reviewing, data analysis, customer support scripts, code review standards — and you run into a few concrete issues:
1. Prompt bloat. Every single request pays the token cost of every domain's instructions, even if the user only asked about SQL. That's slower and more expensive for no benefit.
2. Instructions start to blur together. When the SQL rules, the email rules, and the debugging rules all sit in the same block of text, the model has to sift through everything at once. It's easy for instructions from one domain to bleed into another, or for the model to lose track of which rule applies where.
3. It doesn't scale. Every time you want to add a new specialty, you're editing one long, increasingly fragile prompt. Testing becomes harder too, because a change meant for the "debugger" instructions might accidentally affect how the model writes emails.
4. Most of it is irrelevant most of the time. If a user asks for help writing an email, the model doesn't need to know your SQL injection rules while it's doing that. Carrying them along anyway is just dead weight in every single call.
Basically: stuffing everything into one prompt treats all knowledge as always-relevant, when in reality only a slice of it is relevant to any given request.
The fix: load expertise only when it's needed
The skills pattern solves this with an idea borrowed from good software design: progressive disclosure. Don't show everything upfront — reveal detail only when it's actually needed.
Instead of one giant prompt containing every domain's rules, each domain's rules live separately as a "skill." The agent starts with a lightweight base prompt and a single tool: load_skill. When it recognizes a request needs specialized knowledge, it calls that tool, which fetches the relevant instructions and injects them into the prompt — but only for that turn, only for that domain.
Let's look at how the example code builds this.
1. Skills are just stored prompts
SKILLS = {
"sql_expert": """
You are a SQL expert. Follow these rules:
- Write queries using only SELECT, INSERT, UPDATE, DELETE
- Always use parameterized queries to prevent SQL injection
- Format SQL keywords in UPPERCASE
- Include brief comments for complex queries
""",
"email_writer": """
You are an email writing specialist. Follow these rules:
...
""",
"debugger": """
You are a debugging specialist. Follow these rules:
...
""",
}
Nothing fancy here — it's a dictionary mapping a skill name to a block of instructions. Each one is self-contained. Notice the base agent doesn't need to know any of this content up front. It just needs to know these skills exist and roughly what they're for.
2. State tracks which skill is currently active
class SkillState(AgentState):
loaded_skill: str = ""
Same idea as the handoffs pattern — a piece of state that starts empty and gets filled in once the agent decides it needs a specialty.
3. A tool that fetches the skill and saves it to state
@tool
def load_skill(
skill_name: str,
runtime: ToolRuntime[None, SkillState],
) -> Command:
"""Load a specialized skill prompt.
Available skills:
- sql_expert: SQL query writing and optimization
- email_writer: Professional email composition
- debugger: Troubleshooting and debugging
"""
skill = SKILLS.get(skill_name)
if not skill:
return Command(
update={
"messages": [
ToolMessage(
content=f"Skill '{skill_name}' not found. Available: {', '.join(SKILLS.keys())}",
tool_call_id=runtime.tool_call_id,
)
],
"loaded_skill": "",
},
)
return Command(
update={
"messages": [
ToolMessage(
content=f"Loaded skill: {skill_name}",
tool_call_id=runtime.tool_call_id,
)
],
"loaded_skill": skill,
},
)
A few things worth pointing out:
- The tool's docstring is what the model actually sees to decide when and which skill to load. It lists the available skills and a short description of each — this is the only "index" of specialties the base prompt needs to carry around, not the full instructions themselves.
- If the model asks for a skill that doesn't exist, the tool doesn't crash — it responds with a helpful message listing what is available, so the model can try again.
- On success, the actual instructions text gets saved into
loaded_skillin state. That's the moment the specialized knowledge enters the picture.
4. Middleware injects the loaded skill into the prompt
@wrap_model_call
def inject_skill(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
skill = request.state.get("loaded_skill", "")
if skill:
request = request.override(
system_prompt=(
f"You have loaded the following skill. Follow these instructions:\n\n{skill}\n\n"
f"(If you need a different skill, call load_skill again.)"
),
)
return handler(request)
This runs before every model call, same as in the handoffs example. If a skill has been loaded, it rewrites the system prompt to include those specific instructions — and only those. If no skill is loaded yet, the prompt stays as the plain base prompt.
This is the part that actually delivers on "progressive disclosure": the SQL rules only ever enter the prompt when sql_expert has been loaded. The email rules never show up unless email_writer was requested. There's no moment where the model is carrying instructions for a domain it isn't currently working in.
5. Wiring it together
agent = create_agent(
model=free_llm,
tools=[load_skill],
state_schema=SkillState,
middleware=[inject_skill],
system_prompt=(
"You are a helpful assistant with access to specialized skills.\n"
"When a user asks something in a specific domain:\n"
"1. Call load_skill to get specialized instructions\n"
"2. Follow those instructions to answer\n\n"
"Available skills: sql_expert, email_writer, debugger"
),
)
Notice how short the base system_prompt is. It doesn't contain a single SQL rule, email format, or debugging step. It just tells the model that skills exist and how to fetch them. That's the whole point — the base prompt stays small and stable no matter how many skills you add later.
Walking through a run
response = agent.invoke({
"messages": [{
"role": "user",
"content": "Write a SQL query to find the top 5 customers by total purchase amount."
}],
})
Here's what happens step by step:
- The model gets the base prompt — no domain-specific knowledge yet.
- It reads the request, recognizes this is a SQL task, and calls
load_skill(skill_name="sql_expert"). - The tool looks up
"sql_expert"inSKILLS, finds it, and returns aCommandthat stores the full instruction text intoloaded_skill. - The agent loops back to call the model again. This time, the middleware sees
loaded_skillis no longer empty, and rewrites the system prompt to include the SQL expert rules. - Now, and only now, does the model know to use uppercase SQL keywords, avoid injection-prone patterns, and add comments for complex queries.
- It writes the query following those rules and responds to the user.
If the very next message had asked for help writing an email instead, the model would call load_skill("email_writer"), and the SQL instructions would be swapped out entirely for the email ones. Nothing lingers.
Why this actually matters
Going back to the problems from the start:
- No more prompt bloat. The base prompt is a handful of lines. Specialized instructions only get added when they're relevant, so most requests aren't paying for knowledge they don't use.
- No more blurred instructions. Each skill is isolated. There's no risk of SQL formatting rules quietly influencing how an email gets written, because they're never in the prompt at the same time.
-
It scales. Adding a new specialty — say, a
code_reviewerskill — means adding one new entry to theSKILLSdictionary and one line to the tool's docstring. The base agent, the middleware, and every existing skill stay untouched. - Only relevant knowledge shows up, when it's relevant. This is really the core benefit. The model's attention isn't split across a dozen unrelated rule sets; it's focused on exactly the one it needs for the task in front of it.
A mental model to keep
Think of it like a reference library instead of a textbook stapled together from every subject. You don't hand someone the entire library every time they ask a question. You point them to the index (the skill names and short descriptions), and they check out the one book they actually need for this particular question. Everything else stays on the shelf, ready if it's needed later, but not cluttering the desk in the meantime.
That's the whole idea behind skills in LangChain: keep the base prompt lean, describe what expertise is available, and let the agent pull in the details only when a request actually calls for them.
Full code
"""Skills Demo
===========
Skills are specialized prompts loaded on-demand by the agent (progressive disclosure).
Instead of bloating the main prompt with every possible specialization,
the agent uses a load_skill tool to fetch the right instructions when needed.
Flow:
Agent gets a request -> calls load_skill("domain")
-> skill instructions are saved into state
-> middleware injects them into the system prompt
-> agent now has specialized knowledge to answer
"""
import os
from dotenv import load_dotenv
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from langchain.messages import ToolMessage
from langchain.tools import tool, ToolRuntime
from langchain_openai import ChatOpenAI
from langgraph.types import Command
from typing import Callable
load_dotenv()
class SkillState(AgentState):
loaded_skill: str = ""
SKILLS = {
"sql_expert": """
You are a SQL expert. Follow these rules:
- Write queries using only SELECT, INSERT, UPDATE, DELETE
- Always use parameterized queries to prevent SQL injection
- Format SQL keywords in UPPERCASE
- Include brief comments for complex queries
""",
"email_writer": """
You are an email writing specialist. Follow these rules:
- Keep emails concise and professional
- Use clear subject lines
- Structure: greeting -> purpose -> details -> call to action -> sign-off
- Adjust tone based on context: formal for clients, casual for team
""",
"debugger": """
You are a debugging specialist. Follow these rules:
- First reproduce the error
- Check logs and error messages
- Isolate the root cause
- Suggest a fix with explanation
""",
}
@tool
def load_skill(
skill_name: str,
runtime: ToolRuntime[None, SkillState],
) -> Command:
"""Load a specialized skill prompt.
Available skills:
- sql_expert: SQL query writing and optimization
- email_writer: Professional email composition
- debugger: Troubleshooting and debugging
"""
skill = SKILLS.get(skill_name)
if not skill:
return Command(
update={
"messages": [
ToolMessage(
content=f"Skill '{skill_name}' not found. Available: {', '.join(SKILLS.keys())}",
tool_call_id=runtime.tool_call_id,
)
],
"loaded_skill": "",
},
)
return Command(
update={
"messages": [
ToolMessage(
content=f"Loaded skill: {skill_name}",
tool_call_id=runtime.tool_call_id,
)
],
"loaded_skill": skill,
},
)
@wrap_model_call
def inject_skill(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
skill = request.state.get("loaded_skill", "")
if skill:
request = request.override(
system_prompt=(
f"You have loaded the following skill. Follow these instructions:\n\n{skill}\n\n"
f"(If you need a different skill, call load_skill again.)"
),
)
return handler(request)
free_llm = ChatOpenAI(
model="nvidia/nemotron-nano-9b-v2:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.environ.get("OPENROUTER_API_KEY"),
)
agent = create_agent(
model=free_llm,
tools=[load_skill],
state_schema=SkillState,
middleware=[inject_skill],
system_prompt=(
"You are a helpful assistant with access to specialized skills.\n"
"When a user asks something in a specific domain:\n"
"1. Call load_skill to get specialized instructions\n"
"2. Follow those instructions to answer\n\n"
"Available skills: sql_expert, email_writer, debugger"
),
)
print("=" * 60)
print("Skills Demo - Progressive Disclosure")
print("=" * 60)
response = agent.invoke({
"messages": [{
"role": "user",
"content": "Write a SQL query to find the top 5 customers by total purchase amount."
}],
})
print("\nFull conversation:")
for msg in response["messages"]:
role = msg.__class__.__name__.replace("Message", "")
if hasattr(msg, "content") and msg.content:
print(f" [{role}] {msg.content[:300]}")
elif hasattr(msg, "tool_calls") and msg.tool_calls:
for tc in msg.tool_calls:
print(f" [{role}] Tool: {tc['name']}({tc['args']})")
Try it yourself
A natural next step is adding a skill that combines with another — for example, having the debugger skill available at the same time as sql_expert, for cases where someone needs help fixing a broken query. You'd need to:
- Decide whether
loaded_skillshould hold one skill or a list of active skills - Update
inject_skillto combine multiple loaded skills into the prompt if more than one is active
That one change opens the door to combining specialties instead of just switching between them — while keeping the same core pattern.
Top comments (0)