Module 4: Middleware and Customization
Dynamic Tools and Dynamic Prompts
Capsule overview
In the previous capsule you learned to swap the model dynamically based on complexity. But there are two more dimensions you can personalize at runtime: the available tools and the agent's system prompt.
Not every user should have access to every tool. A user with the admin role can delete records; a basic user cannot. A free-tier user gets 3 tools; a premium-tier user gets 10. If you expose every tool to every user, you're creating a security problem and confusing the model with irrelevant options.
The same goes for the system prompt. A static prompt works for prototypes, but in production you want the prompt to adapt: include the user's name, describe only the available tools, adjust the tone to the context, or add specific instructions based on the state of the conversation.
In LangChain v1.2+, create_agent accepts a function instead of a list for tools, and a callable for prompt. That gives you full control over which tools and which prompt the agent sees on every invocation — without rewriting the agent.
Dynamic tools: the concept
Normally you pass a static list of tools to the agent:
# Static: always the same tools
agent = create_agent(model, tools=[search, calculator, delete_record])
With dynamic tools, you pass a function that receives the state and returns the appropriate list of tools:
# Dynamic: the tools change with the state
agent = create_agent(model, tools=get_tools_for_user)
Why does it matter?
When an LLM receives tools in its context, it treats them as valid options. If you hand delete_record to a user without permissions, the model may try to use it — and your system has to handle the rejection. It's better not to give it the option at all: if the user can't delete records, the model shouldn't even know that tool exists.
Static approach:
Model receives: [search, calculator, delete_record]
→ May try delete_record → Permission error → Bad UX
Dynamic approach:
Model receives: [search, calculator] (no delete_record)
→ Doesn't even know it exists → Never tries it → Clean
Implementing dynamic tools with create_agent
The most direct way: pass a function to the tools parameter of create_agent. That function receives the current state and returns the list of tools.
Filtering by user role
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for information about a topic."""
return f"Result: {query} is a relevant concept."
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
@tool
def delete_record(record_id: str) -> str:
"""Delete a record from the database. Administrators only."""
return f"Record {record_id} deleted successfully."
@tool
def modify_config(key: str, value: str) -> str:
"""Modify the system configuration. Administrators only."""
return f"Configuration {key}={value} updated."
def get_tools_for_user(state) -> list:
"""Return tools based on the user's role."""
user_role = state.get("user_role", "basic")
base_tools = [search, calculator]
admin_tools = [search, calculator, delete_record, modify_config]
if user_role == "admin":
print(f"[TOOLS] Role: admin → {len(admin_tools)} tools available")
return admin_tools
print(f"[TOOLS] Role: {user_role} → {len(base_tools)} tools available")
return base_tools
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
model,
tools=get_tools_for_user,
)
result_basic = agent.invoke({
"messages": [("user", "Search for information about Python")],
"user_role": "basic",
})
print(f"[basic] Answer: {result_basic['messages'][-1].content[:80]}")
print()
result_admin = agent.invoke({
"messages": [("user", "Delete the record with id=42")],
"user_role": "admin",
})
print(f"[admin] Answer: {result_admin['messages'][-1].content[:80]}")
# Expected output:
# [TOOLS] Role: basic → 2 tools available
# [basic] Answer: Python is a high-level, interpreted, general-purpose programming langua...
#
# [TOOLS] Role: admin → 4 tools available
# [admin] Answer: The record with id=42 has been deleted successfully.
The basic agent never even sees delete_record — it can't try to use it because it doesn't exist in its context.
Use cases: filtering tools by context
Free vs Paid tier
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search_web(query: str) -> str:
"""Search the web for information."""
return f"Web result: {query} found."
@tool
def search_academic(query: str) -> str:
"""Search academic databases (requires a premium plan)."""
return f"Paper found: '{query}' - Journal of AI Research, 2025."
@tool
def summarize(text: str) -> str:
"""Summarize a long text."""
return f"Summary: {text[:50]}..."
@tool
def translate(text: str, target_lang: str) -> str:
"""Translate text into another language (requires a premium plan)."""
return f"[{target_lang}] {text}"
@tool
def export_pdf(content: str) -> str:
"""Export content to PDF (requires a premium plan)."""
return f"PDF generated with {len(content)} characters."
TIER_TOOLS = {
"free": [search_web, summarize],
"premium": [search_web, search_academic, summarize, translate, export_pdf],
}
def get_tools_by_tier(state) -> list:
"""Return tools based on the user's tier."""
tier = state.get("user_tier", "free")
tools = TIER_TOOLS.get(tier, TIER_TOOLS["free"])
tool_names = [t.name for t in tools]
print(f"[TIER] {tier} → tools: {tool_names}")
return tools
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=get_tools_by_tier)
result_free = agent.invoke({
"messages": [("user", "Search for information about machine learning")],
"user_tier": "free",
})
print(f"[free] {result_free['messages'][-1].content[:80]}\n")
result_premium = agent.invoke({
"messages": [("user", "Find academic papers about transformers and translate them into Spanish")],
"user_tier": "premium",
})
print(f"[premium] {result_premium['messages'][-1].content[:80]}")
# Expected output:
# [TIER] free → tools: ['search_web', 'summarize']
# [free] Machine learning is a field of artificial intelligence that lets systems lear...
#
# [TIER] premium → tools: ['search_web', 'search_academic', 'summarize', 'translate', 'export_pdf']
# [premium] I found a relevant paper: 'transformers' from the Journal of AI Research, 2025...
Production vs Development
from dotenv import load_dotenv
load_dotenv()
import os
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for information."""
return f"Result: {query}."
@tool
def query_database(sql: str) -> str:
"""Run a SQL query against the database."""
return f"Query executed: {sql} → 42 rows returned."
@tool
def debug_inspect(variable: str) -> str:
"""Inspect a system variable (development only)."""
return f"DEBUG: {variable} = {{type: 'dict', keys: ['a', 'b'], memory: '2.3MB'}}"
@tool
def reset_database(confirm: str) -> str:
"""Reset the database to its initial state (development only)."""
return f"Database reset. Confirm={confirm}"
def get_tools_by_environment(state) -> list:
"""Return tools based on the environment."""
env = state.get("environment", os.getenv("APP_ENV", "production"))
prod_tools = [search, query_database]
dev_tools = [search, query_database, debug_inspect, reset_database]
if env == "development":
print(f"[ENV] development → {len(dev_tools)} tools (debug included)")
return dev_tools
print(f"[ENV] production → {len(prod_tools)} tools (no debug)")
return prod_tools
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=get_tools_by_environment)
result = agent.invoke({
"messages": [("user", "Search for information about Docker")],
"environment": "production",
})
print(f"[prod] {result['messages'][-1].content[:80]}")
# Expected output:
# [ENV] production → 2 tools (no debug)
# [prod] Docker is a containerization platform that lets you package applications...
Registering tools at runtime
Sometimes the tools aren't predefined. They can come from MCP servers, plugins, or APIs discovered at runtime. You can build tools dynamically and hand them to the agent.
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool, StructuredTool
from pydantic import BaseModel, Field
@tool
def search(query: str) -> str:
"""Search for information about a topic."""
return f"Result: {query} is a relevant concept."
class APIEndpointInput(BaseModel):
endpoint: str = Field(description="The API endpoint to call")
method: str = Field(default="GET", description="HTTP method")
def create_api_tool(api_name: str, base_url: str) -> StructuredTool:
"""Create a dynamic tool for a specific API."""
def call_api(endpoint: str, method: str = "GET") -> str:
url = f"{base_url}{endpoint}"
return f"[{api_name}] {method} {url} → 200 OK (simulated data)"
return StructuredTool.from_function(
func=call_api,
name=f"call_{api_name.lower().replace(' ', '_')}",
description=f"Call the {api_name} API. Base URL: {base_url}",
args_schema=APIEndpointInput,
)
AVAILABLE_APIS = {
"weather": {"name": "Weather Service", "url": "https://api.weather.example.com"},
"news": {"name": "News Service", "url": "https://api.news.example.com"},
"stocks": {"name": "Stock Market", "url": "https://api.stocks.example.com"},
}
def get_tools_with_apis(state) -> list:
"""Return the base tools plus dynamic tools for the connected APIs."""
base_tools = [search]
connected_apis = state.get("connected_apis", [])
for api_key in connected_apis:
if api_key in AVAILABLE_APIS:
api_info = AVAILABLE_APIS[api_key]
api_tool = create_api_tool(api_info["name"], api_info["url"])
base_tools.append(api_tool)
tool_names = [t.name for t in base_tools]
print(f"[RUNTIME] Connected APIs: {connected_apis} → tools: {tool_names}")
return base_tools
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=get_tools_with_apis)
result = agent.invoke({
"messages": [("user", "What's the current weather and today's news?")],
"connected_apis": ["weather", "news"],
})
print(f"\nAnswer: {result['messages'][-1].content[:120]}")
# Expected output:
# [RUNTIME] Connected APIs: ['weather', 'news'] → tools: ['search', 'call_weather_service', 'call_news_service']
#
# Answer: Based on the data retrieved, current weather conditions look favorable and today's news covers topics rel...
The tools are created at invocation time, based on which APIs the user has connected. If you add a new API tomorrow, you don't have to touch the agent.
Dynamic prompts: beyond static strings
A static system prompt works for simple cases:
# Static
agent = create_agent(model, tools, prompt="You are a helpful assistant.")
But in production you need the prompt to adapt. create_agent accepts a callable that receives the state and returns the prompt:
# Dynamic
agent = create_agent(model, tools, prompt=generate_prompt)
A prompt built from the user
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for information about a topic."""
return f"Result: {query} is a key concept in technology."
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
def generate_prompt(state) -> str:
"""Build the system prompt from the current state."""
user_name = state.get("user_name", "user")
user_role = state.get("user_role", "basic")
language = state.get("language", "en")
role_instructions = {
"admin": "You have full system access. You may run destructive operations if the user confirms them.",
"basic": "You have limited access. You cannot delete data or change configuration.",
}
lang_instructions = {
"es": "Always respond in Spanish.",
"en": "Always respond in English.",
}
prompt = f"""You are an intelligent assistant. The user's name is {user_name}.
{role_instructions.get(user_role, role_instructions['basic'])}
{lang_instructions.get(language, lang_instructions['en'])}
Be concise and direct in your answers."""
print(f"[PROMPT] Built for: {user_name} (role={user_role}, lang={language})")
return prompt
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
model,
tools=[search, calculator],
prompt=generate_prompt,
)
result = agent.invoke({
"messages": [("user", "Who am I and what can I do?")],
"user_name": "Carlos",
"user_role": "admin",
"language": "en",
})
print(f"\nAnswer: {result['messages'][-1].content[:150]}")
# Expected output:
# [PROMPT] Built for: Carlos (role=admin, lang=en)
#
# Answer: Hi Carlos! You're an administrator with full system access. You can search for information, run calculations, delete records, and change confi...
A prompt that lists the available tools
A powerful pattern: the prompt describes the tools the agent has. If the tools change dynamically, the prompt has to reflect that.
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for general information about a topic."""
return f"Result: {query} is an important concept."
@tool
def calculator(expression: str) -> str:
"""Evaluate math expressions."""
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
@tool
def delete_record(record_id: str) -> str:
"""Delete a record from the database."""
return f"Record {record_id} deleted."
ALL_TOOLS = {
"search": search,
"calculator": calculator,
"delete_record": delete_record,
}
TOOL_DESCRIPTIONS = {
"search": "look up general information",
"calculator": "run math calculations",
"delete_record": "delete records from the database",
}
def get_tools_for_state(state) -> list:
user_role = state.get("user_role", "basic")
if user_role == "admin":
return list(ALL_TOOLS.values())
return [ALL_TOOLS["search"], ALL_TOOLS["calculator"]]
def generate_prompt_with_tools(state) -> str:
"""Build a prompt that describes the available tools."""
user_role = state.get("user_role", "basic")
user_name = state.get("user_name", "user")
available_tool_names = [t.name for t in get_tools_for_state(state)]
tool_list = "\n".join(
f"- {name}: {TOOL_DESCRIPTIONS[name]}"
for name in available_tool_names
)
prompt = f"""You are an assistant for {user_name} (role: {user_role}).
You have access to the following tools:
{tool_list}
Use only the tools listed above. If the user asks for something that needs a tool you don't have, kindly explain that you don't have access to that capability."""
print(f"[PROMPT] Tools in prompt: {available_tool_names}")
return prompt
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
model,
tools=get_tools_for_state,
prompt=generate_prompt_with_tools,
)
result = agent.invoke({
"messages": [("user", "What can you do for me?")],
"user_name": "Ana",
"user_role": "basic",
})
print(f"\n[basic] {result['messages'][-1].content[:120]}")
# Expected output:
# [PROMPT] Tools in prompt: ['search', 'calculator']
#
# [basic] Hi Ana! I can help you with two main things: looking up general information on any topic and running math calc...
Dynamic prompts from richer context
The prompt can adapt to factors beyond the user: time of day, length of the conversation, error history.
A prompt that adapts to conversation length
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for information about a topic."""
return f"Result: {query} is relevant."
def generate_adaptive_prompt(state) -> str:
"""Adapt the prompt to the length of the conversation."""
messages = state.get("messages", [])
msg_count = len(messages)
if msg_count <= 2:
tone = "Welcome the user and be friendly."
style = "Be thorough in your explanations, the user is just getting started."
elif msg_count <= 6:
tone = "The user already has context, don't repeat welcomes."
style = "Be concise but informative."
else:
tone = "The conversation is long. The user wants direct answers."
style = "Be as brief as possible. Get to the point."
prompt = f"""You are a technical assistant.
Conversation context: {msg_count} messages exchanged.
Tone: {tone}
Style: {style}
Respond in English."""
print(f"[PROMPT] {msg_count} messages → {'welcome' if msg_count <= 2 else 'concise' if msg_count <= 6 else 'direct'}")
return prompt
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=[search], prompt=generate_adaptive_prompt)
result = agent.invoke({
"messages": [("user", "What is Docker?")],
})
print(f"Answer: {result['messages'][-1].content[:100]}")
# Expected output:
# [PROMPT] 1 messages → welcome
# Answer: Welcome! Docker is an open-source platform that lets you build, ship, and run applications in...
A prompt built from the time of day
from dotenv import load_dotenv
load_dotenv()
from datetime import datetime
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for information about a topic."""
return f"Result: {query} is an important concept."
def generate_time_aware_prompt(state) -> str:
"""Build a prompt that adapts to the time of day."""
hour = datetime.now().hour
if 6 <= hour < 12:
greeting = "Good morning"
energy = "The user is probably starting their day. Be energetic and motivating."
elif 12 <= hour < 18:
greeting = "Good afternoon"
energy = "The user is in the middle of their workday. Be efficient and direct."
elif 18 <= hour < 22:
greeting = "Good evening"
energy = "The user may be tired. Be kind and clear."
else:
greeting = "Hi"
energy = "It's very late. Answer as concisely as you possibly can."
prompt = f"""You are a friendly assistant. {energy}
If this is the first interaction, greet the user with "{greeting}".
Respond in English."""
print(f"[PROMPT] Hour: {hour}:00 → {greeting}")
return prompt
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=[search], prompt=generate_time_aware_prompt)
result = agent.invoke({"messages": [("user", "Hi, what can you do?")]})
print(f"Answer: {result['messages'][-1].content[:100]}")
# Expected output (varies with the hour):
# [PROMPT] Hour: 14:00 → Good afternoon
# Answer: Good afternoon! I can help you look up information on any topic you need. What can I help you...
Combining dynamic tools + dynamic prompts
The real power shows up when both change together: the tools get filtered by context AND the prompt adapts to describe only the available tools.
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for general information."""
return f"Result: {query} is a key concept."
@tool
def calculator(expression: str) -> str:
"""Evaluate math expressions."""
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
@tool
def generate_report(topic: str) -> str:
"""Generate a detailed report about a topic (premium)."""
return f"Report on '{topic}': full analysis with 5 sections."
@tool
def export_data(format: str) -> str:
"""Export data in the given format (premium)."""
return f"Data exported in {format} format."
@tool
def delete_user(user_id: str) -> str:
"""Delete a user from the system (admin)."""
return f"User {user_id} deleted from the system."
@tool
def system_config(key: str, value: str) -> str:
"""Modify the system configuration (admin)."""
return f"Config: {key}={value} updated."
ROLE_TOOLS = {
"basic": [search, calculator],
"premium": [search, calculator, generate_report, export_data],
"admin": [search, calculator, generate_report, export_data, delete_user, system_config],
}
ROLE_DESCRIPTIONS = {
"basic": "You have access to search and a calculator.",
"premium": "You have access to search, a calculator, reports and data export.",
"admin": "You have full access, including user management and configuration.",
}
def get_dynamic_tools(state) -> list:
role = state.get("user_role", "basic")
tools = ROLE_TOOLS.get(role, ROLE_TOOLS["basic"])
print(f"[TOOLS] role={role} → {[t.name for t in tools]}")
return tools
def generate_dynamic_prompt(state) -> str:
role = state.get("user_role", "basic")
name = state.get("user_name", "user")
tools = ROLE_TOOLS.get(role, ROLE_TOOLS["basic"])
tool_names = [t.name for t in tools]
capabilities = ROLE_DESCRIPTIONS.get(role, ROLE_DESCRIPTIONS["basic"])
tool_list = ", ".join(tool_names)
prompt = f"""You are an assistant for {name} with the "{role}" role.
Capabilities: {capabilities}
Available tools: {tool_list}
Rules:
- Use only the tools listed
- If they ask for something outside your capabilities, explain which plan they need
- Respond in English, be concise"""
print(f"[PROMPT] {name} ({role}) → {len(tools)} tools")
return prompt
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
model,
tools=get_dynamic_tools,
prompt=generate_dynamic_prompt,
)
print("--- Basic user ---")
result_basic = agent.invoke({
"messages": [("user", "Generate a report about Python")],
"user_name": "Laura",
"user_role": "basic",
})
print(f"[basic] {result_basic['messages'][-1].content[:120]}\n")
print("--- Premium user ---")
result_premium = agent.invoke({
"messages": [("user", "Generate a report about Python and export it as PDF")],
"user_name": "Carlos",
"user_role": "premium",
})
print(f"[premium] {result_premium['messages'][-1].content[:120]}")
# Expected output:
# --- Basic user ---
# [TOOLS] role=basic → ['search', 'calculator']
# [PROMPT] Laura (basic) → 2 tools
# [basic] Sorry Laura, report generation is available on the premium plan. Right now you can search for information and run c...
#
# --- Premium user ---
# [TOOLS] role=premium → ['search', 'calculator', 'generate_report', 'export_data']
# [PROMPT] Carlos (premium) → 4 tools
# [premium] I generated a report on Python with a full 5-section analysis and exported it as PDF. Do you need anything...
The basic user gets an answer that tells them which plan they need. The premium user gets the report and the export. All automatic, with no if/else in your application logic.
Advanced pattern: tools that unlock as the conversation progresses
Tools can change not only with the user, but with the state of the conversation. For example: after the agent searches for information, you unlock the tool that generates a summary.
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_core.messages import ToolMessage
@tool
def search(query: str) -> str:
"""Search for information about a topic."""
return f"Detailed result: {query} is an AI framework with multiple components and production use cases."
@tool
def summarize(text: str) -> str:
"""Summarize information found earlier."""
return f"Summary: {text[:80]}..."
@tool
def deep_analyze(topic: str) -> str:
"""Deep analysis of a topic (available after searching)."""
return f"Analysis: {topic} has 3 key advantages and 2 important limitations."
def get_progressive_tools(state) -> list:
"""Tools that unlock as the conversation progresses."""
messages = state.get("messages", [])
has_search_results = any(
isinstance(msg, ToolMessage) and "Detailed result" in msg.content
for msg in messages
)
base_tools = [search]
if has_search_results:
base_tools.extend([summarize, deep_analyze])
print(f"[PROGRESSIVE] Search detected → +summarize, +deep_analyze")
else:
print(f"[PROGRESSIVE] No prior search → search only")
return base_tools
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=get_progressive_tools)
result = agent.invoke({
"messages": [("user", "Search for what LangChain is and then summarize it for me")],
})
print(f"\nAnswer: {result['messages'][-1].content[:120]}")
# Expected output:
# [PROGRESSIVE] No prior search → search only
# [PROGRESSIVE] Search detected → +summarize, +deep_analyze
#
# Answer: LangChain is an AI framework with multiple components and production use cases. In short, it's a framew...
The agent's first iteration only sees search. After it searches, the second iteration sees summarize and deep_analyze unlocked.
Security considerations
Dynamic tools introduce a security vector you have to handle explicitly.
The principle: defense in depth
Don't rely solely on the model not calling a tool you didn't give it. Add validation inside the tool itself:
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
CURRENT_USER = {"role": "basic", "name": "user"}
@tool
def search(query: str) -> str:
"""Search for information about a topic."""
return f"Result: {query} is a relevant concept."
@tool
def delete_record(record_id: str) -> str:
"""Delete a record from the database. Requires the admin role."""
if CURRENT_USER["role"] != "admin":
return "ERROR: You don't have permission to delete records."
return f"Record {record_id} deleted."
@tool
def modify_config(key: str, value: str) -> str:
"""Modify the system configuration. Requires the admin role."""
if CURRENT_USER["role"] != "admin":
return "ERROR: You don't have permission to modify the configuration."
return f"Config {key}={value} updated."
def get_tools_with_validation(state) -> list:
role = state.get("user_role", "basic")
CURRENT_USER["role"] = role
CURRENT_USER["name"] = state.get("user_name", "user")
if role == "admin":
return [search, delete_record, modify_config]
return [search]
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=get_tools_with_validation)
result = agent.invoke({
"messages": [("user", "Search for information about API security")],
"user_role": "basic",
"user_name": "Test",
})
print(f"Answer: {result['messages'][-1].content[:100]}")
# Expected output:
# Answer: API security is a fundamental aspect of modern software development covering authenticati...
Security rules for dynamic tools
- ✅ Always filter tools by role — the model shouldn't see tools the user can't use
- ✅ Validate permissions inside every tool — defense in depth, in case the filtering fails
- ✅ Log every invocation of sensitive tools — for auditing
- ⚠️ Don't rely on the prompt alone — a prompt that says "don't use delete_record" is not security
- ❌ Never expose destructive tools to external users without double confirmation
- ❌ Never put credentials or tokens inside tools — use environment variables
Connection to the project
In the module project (Capsule 08) you'll combine everything you've learned: dynamic model routing (Capsule 05) with dynamic tools and dynamic prompts, to build an agent that picks the model by complexity, filters tools by the user's role, and generates a personalized prompt describing the available capabilities. The AgentMiddleware class (Capsule 07) will let you package all of this into reusable modules.
Troubleshooting
Problem 1: The tools function receives an empty state
Cause: You aren't passing the required fields in the agent's input (such as user_role).
Fix: Make sure the fields are in the invocation dictionary:
result = agent.invoke({
"messages": [("user", "Question")],
"user_role": "admin", # Include the fields your function needs
})
Problem 2: The dynamic prompt isn't applied
Cause: You passed a string instead of a function to the prompt parameter.
Fix: prompt has to be a callable (a function), not a string:
# Wrong
agent = create_agent(model, tools, prompt="You are an assistant.")
# Right for a dynamic prompt
agent = create_agent(model, tools, prompt=generate_prompt)
Problem 3: Dynamic tools cause "tool not found" errors
Cause: The agent decided to call a tool on the first iteration, but on the second iteration that tool is no longer available because the state changed. Fix: Make sure your tools function returns a consistent set within a single conversation, or add error handling:
def get_tools(state):
tools = [search] # Always include the base tools
# Only add, never remove during a conversation
if state.get("can_analyze"):
tools.append(analyze)
return tools
Problem 4: The prompt doesn't reflect the available tools
Cause: The prompt function and the tools function don't share the same filtering logic. Fix: Use the same source of truth for both:
ROLE_TOOLS = {"basic": [search], "admin": [search, delete]}
def get_tools(state):
role = state.get("user_role", "basic")
return ROLE_TOOLS.get(role, ROLE_TOOLS["basic"])
def generate_prompt(state):
role = state.get("user_role", "basic")
tools = ROLE_TOOLS.get(role, ROLE_TOOLS["basic"]) # Same source
tool_names = ", ".join(t.name for t in tools)
return f"Tools: {tool_names}"
Problem 5: Dynamic StructuredTools don't work with the model
Cause: The dynamically created tool doesn't have a correct args_schema, or its docstring is empty.
Fix: Always define a Pydantic args_schema and a clear description:
from pydantic import BaseModel, Field
from langchain_core.tools import StructuredTool
class MyInput(BaseModel):
query: str = Field(description="The query to run")
tool = StructuredTool.from_function(
func=my_func,
name="my_tool",
description="A clear description of what this tool does",
args_schema=MyInput,
)
Exercises
Exercise 1: Tools by role (Easy)
Build an agent with dynamic tools that filters by two roles: viewer (only search) and editor (search + create_note). Test it with both roles.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for information about a topic."""
return f"Result: {query} is a topic of interest."
@tool
def create_note(title: str, content: str) -> str:
"""Create a new note."""
return f"Note '{title}' created with {len(content)} characters."
def get_tools_by_role(state) -> list:
role = state.get("user_role", "viewer")
if role == "editor":
print(f"[TOOLS] editor → [search, create_note]")
return [search, create_note]
print(f"[TOOLS] viewer → [search]")
return [search]
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=get_tools_by_role)
print("--- Viewer ---")
result = agent.invoke({
"messages": [("user", "Search for information about LangChain")],
"user_role": "viewer",
})
print(f"Answer: {result['messages'][-1].content[:80]}\n")
print("--- Editor ---")
result = agent.invoke({
"messages": [("user", "Create a note titled 'Summary' with the content 'LangChain is great'")],
"user_role": "editor",
})
print(f"Answer: {result['messages'][-1].content[:80]}")
# Expected output:
# --- Viewer ---
# [TOOLS] viewer → [search]
# Answer: LangChain is an open-source framework for building applications with langua...
#
# --- Editor ---
# [TOOLS] editor → [search, create_note]
# Answer: I created the note titled 'Summary' with the content 'LangChain is great'.
Explanation: get_tools_by_role checks the user_role field in the state and returns only the allowed tools. The viewer can only search; the editor can also create notes.
Exercise 2: Dynamic prompt with a name (Easy)
Build a dynamic prompt that includes the user's name and a personalized greeting based on the time of day (morning, afternoon, evening).
See solution
from dotenv import load_dotenv
load_dotenv()
from datetime import datetime
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for information about a topic."""
return f"Result: {query} is a relevant concept."
def generate_personalized_prompt(state) -> str:
name = state.get("user_name", "user")
hour = datetime.now().hour
if 6 <= hour < 12:
greeting = f"Good morning, {name}"
elif 12 <= hour < 20:
greeting = f"Good afternoon, {name}"
else:
greeting = f"Good evening, {name}"
prompt = f"""You are a personal assistant. Greet the user with "{greeting}" if this is the first interaction.
Respond in English, concisely and warmly."""
print(f"[PROMPT] Greeting: {greeting}")
return prompt
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=[search], prompt=generate_personalized_prompt)
result = agent.invoke({
"messages": [("user", "Hi, what can you do?")],
"user_name": "María",
})
print(f"Answer: {result['messages'][-1].content[:120]}")
# Expected output (varies with the hour):
# [PROMPT] Greeting: Good afternoon, María
# Answer: Good afternoon, María! I can help you look up information on any topic you need. What can I do for you?
Explanation: The prompt is generated at runtime with the user's name and a greeting that fits the current hour. The model receives this personalized context on every invocation.
Exercise 3: Tiers with a descriptive prompt (Medium)
Implement a 3-tier system (free, pro, enterprise) where each tier has different tools AND a prompt that explains what it can do. The free user gets a prompt that mentions the pro tier's features as an upgrade.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for general information."""
return f"Result: {query} found."
@tool
def summarize(text: str) -> str:
"""Summarize long texts (Pro+)."""
return f"Summary: {text[:60]}..."
@tool
def translate(text: str, lang: str) -> str:
"""Translate text (Pro+)."""
return f"[{lang}] {text}"
@tool
def generate_report(topic: str) -> str:
"""Generate detailed reports (Enterprise)."""
return f"Report '{topic}': 10 pages, 5 charts."
@tool
def api_access(endpoint: str) -> str:
"""Direct access to the API (Enterprise)."""
return f"API {endpoint} → 200 OK"
TIER_CONFIG = {
"free": {
"tools": [search],
"upgrade_msg": "\n\nPro plan features: summaries, translations. Mention them if the user needs more.",
},
"pro": {
"tools": [search, summarize, translate],
"upgrade_msg": "\n\nEnterprise features: reports, API access. Mention them if relevant.",
},
"enterprise": {
"tools": [search, summarize, translate, generate_report, api_access],
"upgrade_msg": "",
},
}
def get_tier_tools(state) -> list:
tier = state.get("tier", "free")
tools = TIER_CONFIG.get(tier, TIER_CONFIG["free"])["tools"]
print(f"[TOOLS] tier={tier} → {[t.name for t in tools]}")
return tools
def generate_tier_prompt(state) -> str:
tier = state.get("tier", "free")
name = state.get("user_name", "user")
config = TIER_CONFIG.get(tier, TIER_CONFIG["free"])
tool_names = ", ".join(t.name for t in config["tools"])
prompt = f"""You are an assistant for {name} (plan: {tier}).
Available tools: {tool_names}
Use only these tools.{config['upgrade_msg']}
Respond in English."""
print(f"[PROMPT] tier={tier}, tools={tool_names}")
return prompt
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=get_tier_tools, prompt=generate_tier_prompt)
print("--- Free ---")
result = agent.invoke({
"messages": [("user", "Translate 'hello world' into Spanish")],
"user_name": "Pedro",
"tier": "free",
})
print(f"Answer: {result['messages'][-1].content[:120]}\n")
print("--- Pro ---")
result = agent.invoke({
"messages": [("user", "Translate 'hello world' into Spanish")],
"user_name": "Ana",
"tier": "pro",
})
print(f"Answer: {result['messages'][-1].content[:120]}")
# Expected output:
# --- Free ---
# [TOOLS] tier=free → ['search']
# [PROMPT] tier=free, tools=search
# Answer: I don't have translation available on your current plan. With the Pro plan you'd get translations and summ...
#
# --- Pro ---
# [TOOLS] tier=pro → ['search', 'summarize', 'translate']
# [PROMPT] tier=pro, tools=search, summarize, translate
# Answer: Done! The translation of 'hello world' into Spanish is: "hola mundo".
Explanation: Each tier has a set of tools and a prompt that mentions the next tier as an upgrade. The free user can't translate but gets an upgrade suggestion. The pro user gets the translation directly.
Exercise 4: Progressive tools per conversation (Medium)
Build a system where the agent starts with only search, and once the user has run at least one successful search, summarize and analyze unlock.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_core.messages import ToolMessage
@tool
def search(query: str) -> str:
"""Search for information about a topic."""
return f"Result: {query} — detailed information found across 3 sources."
@tool
def summarize(text: str) -> str:
"""Summarize the information found (requires a prior search)."""
return f"Summary: {text[:50]}... (condensed into 2 paragraphs)"
@tool
def analyze(topic: str) -> str:
"""Analyze a topic in depth (requires a prior search)."""
return f"Analysis of '{topic}': 3 pros, 2 cons, positive trend."
def get_progressive_tools(state) -> list:
messages = state.get("messages", [])
has_search = any(
isinstance(msg, ToolMessage) and "Result" in msg.content
for msg in messages
)
if has_search:
tools = [search, summarize, analyze]
print(f"[PROGRESSIVE] Prior search detected → {[t.name for t in tools]}")
else:
tools = [search]
print(f"[PROGRESSIVE] No search yet → [search] only")
return tools
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=get_progressive_tools)
result = agent.invoke({
"messages": [("user", "Search for what LangChain is and analyze its advantages")],
})
print(f"\nAnswer: {result['messages'][-1].content[:120]}")
# Expected output:
# [PROGRESSIVE] No search yet → [search] only
# [PROGRESSIVE] Prior search detected → ['search', 'summarize', 'analyze']
#
# Answer: LangChain is a framework for LLM applications. The analysis shows 3 pros, 2 cons and a positive trend in...
Explanation: The agent's first iteration can only search. After it gets results, the second iteration detects the ToolMessage containing "Result" and unlocks summarize and analyze.
Exercise 5: Dynamic tools + dynamic prompt + logging (Hard)
Build a complete system that combines: tools filtered by role, a prompt personalized by name and role, and a log of every invocation showing which tools were provided and which prompt was generated.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for general information."""
return f"Result: {query} is an important concept."
@tool
def calculator(expression: str) -> str:
"""Evaluate math expressions."""
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
@tool
def export_csv(data: str) -> str:
"""Export data to CSV (requires the editor role or above)."""
return f"CSV exported: {len(data)} characters."
@tool
def manage_users(action: str, user_id: str) -> str:
"""Manage system users (admin only)."""
return f"Action '{action}' executed for user {user_id}."
ROLE_CONFIG = {
"viewer": {
"tools": [search],
"description": "You can search for information.",
},
"editor": {
"tools": [search, calculator, export_csv],
"description": "You can search, calculate and export data.",
},
"admin": {
"tools": [search, calculator, export_csv, manage_users],
"description": "You have full system access.",
},
}
invocation_log = []
def get_tools(state) -> list:
role = state.get("user_role", "viewer")
config = ROLE_CONFIG.get(role, ROLE_CONFIG["viewer"])
return config["tools"]
def generate_prompt(state) -> str:
role = state.get("user_role", "viewer")
name = state.get("user_name", "user")
config = ROLE_CONFIG.get(role, ROLE_CONFIG["viewer"])
tools = config["tools"]
tool_names = [t.name for t in tools]
prompt = f"""You are an assistant for {name} (role: {role}).
{config['description']}
Tools: {', '.join(tool_names)}
Respond in English, concisely."""
invocation_log.append({
"user": name,
"role": role,
"tools_count": len(tools),
"tools": tool_names,
"prompt_length": len(prompt),
})
print(f"[LOG] {name} ({role}) → {len(tools)} tools, prompt={len(prompt)} chars")
return prompt
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=get_tools, prompt=generate_prompt)
test_users = [
{"name": "Luis", "role": "viewer", "msg": "Search for what Docker is"},
{"name": "Ana", "role": "editor", "msg": "What is 15 * 23?"},
{"name": "Admin", "role": "admin", "msg": "Deactivate user U-789"},
]
for user in test_users:
print(f"\n--- {user['name']} ({user['role']}) ---")
result = agent.invoke({
"messages": [("user", user["msg"])],
"user_name": user["name"],
"user_role": user["role"],
})
print(f"Answer: {result['messages'][-1].content[:80]}")
print(f"\n{'='*55}")
print(f" INVOCATION LOG")
print(f"{'='*55}")
for entry in invocation_log:
print(f" {entry['user']:>6} ({entry['role']:>6}) → {entry['tools_count']} tools: {entry['tools']}")
# Expected output:
# --- Luis (viewer) ---
# [LOG] Luis (viewer) → 1 tools, prompt=120 chars
# Answer: Docker is a containerization platform that lets you package applications...
#
# --- Ana (editor) ---
# [LOG] Ana (editor) → 3 tools, prompt=155 chars
# Answer: 15 × 23 = 345.
#
# --- Admin (admin) ---
# [LOG] Admin (admin) → 4 tools, prompt=170 chars
# Answer: The deactivation has been executed for user U-789.
#
# =======================================================
# INVOCATION LOG
# =======================================================
# Luis (viewer) → 1 tools: ['search']
# Ana (editor) → 3 tools: ['search', 'calculator', 'export_csv']
# Admin ( admin) → 4 tools: ['search', 'calculator', 'export_csv', 'manage_users']
Explanation: The system combines three kinds of dynamism: tools by role, a personalized prompt, and logging of every invocation. The log shows exactly which configuration each user got — useful for auditing.
Exercise 6: A system with dynamic MCP tools (Hard)
Simulate a system where the user can "connect" external APIs. Each connected API adds a dynamic tool to the agent. The prompt updates to describe the available APIs.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import StructuredTool
from langchain_core.tools import tool
from pydantic import BaseModel, Field
@tool
def search(query: str) -> str:
"""Search for general information."""
return f"Result: {query} is relevant."
class APICallInput(BaseModel):
endpoint: str = Field(description="API endpoint")
params: str = Field(default="", description="Query parameters")
API_CATALOG = {
"weather": {
"name": "Weather API",
"description": "Look up current weather and forecast",
"base_url": "https://api.weather.example.com",
},
"news": {
"name": "News API",
"description": "Search recent news by topic",
"base_url": "https://api.news.example.com",
},
"stocks": {
"name": "Stocks API",
"description": "Look up stock prices and trends",
"base_url": "https://api.stocks.example.com",
},
"translate": {
"name": "Translation API",
"description": "Translate text between languages",
"base_url": "https://api.translate.example.com",
},
}
def create_api_tool(api_key: str) -> StructuredTool:
api = API_CATALOG[api_key]
def call_api(endpoint: str, params: str = "") -> str:
url = f"{api['base_url']}{endpoint}"
return f"[{api['name']}] GET {url}?{params} → 200 OK (simulated data for {api_key})"
return StructuredTool.from_function(
func=call_api,
name=f"call_{api_key}",
description=f"{api['description']}. Base URL: {api['base_url']}",
args_schema=APICallInput,
)
def get_mcp_tools(state) -> list:
connected = state.get("connected_apis", [])
tools = [search]
for api_key in connected:
if api_key in API_CATALOG:
tools.append(create_api_tool(api_key))
tool_names = [t.name for t in tools]
print(f"[MCP] Connected APIs: {connected} → tools: {tool_names}")
return tools
def generate_mcp_prompt(state) -> str:
connected = state.get("connected_apis", [])
name = state.get("user_name", "user")
api_descriptions = []
for api_key in connected:
if api_key in API_CATALOG:
api = API_CATALOG[api_key]
api_descriptions.append(f"- {api['name']}: {api['description']}")
apis_text = "\n".join(api_descriptions) if api_descriptions else "- No external API connected"
available_apis = [k for k in API_CATALOG if k not in connected]
suggestion = ""
if available_apis:
suggestion = f"\n\nAPIs available to connect: {', '.join(available_apis)}"
return f"""You are an assistant for {name}.
Beyond general search, you have access to these APIs:
{apis_text}
{suggestion}
Respond in English."""
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, tools=get_mcp_tools, prompt=generate_mcp_prompt)
print("--- No APIs ---")
result = agent.invoke({
"messages": [("user", "What's the weather in Madrid?")],
"user_name": "Carlos",
"connected_apis": [],
})
print(f"Answer: {result['messages'][-1].content[:100]}\n")
print("--- With Weather + News ---")
result = agent.invoke({
"messages": [("user", "What's the weather in Madrid and what's in the news today?")],
"user_name": "Carlos",
"connected_apis": ["weather", "news"],
})
print(f"Answer: {result['messages'][-1].content[:120]}")
# Expected output:
# --- No APIs ---
# [MCP] Connected APIs: [] → tools: ['search']
# Answer: I don't have direct access to real-time weather data, but I can look up general information...
#
# --- With Weather + News ---
# [MCP] Connected APIs: ['weather', 'news'] → tools: ['search', 'call_weather', 'call_news']
# Answer: According to the Weather API, conditions in Madrid look favorable. As for today's news, the News API re...
Explanation: The tools are created dynamically with StructuredTool.from_function for each connected API. The prompt adapts to describe the available APIs and suggest the ones that aren't connected yet. This pattern mirrors how MCP servers work in production.
Summary
In this capsule you learned:
- Dynamic tools filter the available tools by state: user role, tier, environment, or how far the conversation has gone
- Passing a function instead of a list to the
toolsparameter ofcreate_agentturns on dynamic filtering - Dynamic prompts adapt the system prompt at runtime: user's name, time of day, available tools, conversation length
- Passing a callable to the
promptparameter ofcreate_agentturns on dynamic generation - Combining both is the most powerful pattern: the tools change AND the prompt describes exactly what the agent can do
StructuredTool.from_functionlets you create tools at runtime for APIs discovered dynamically (the MCP pattern)- Security: always filter by role + validate inside every tool (defense in depth)
- Never rely on the prompt alone for security — the model can ignore instructions
Next capsule: The AgentMiddleware class — how to compose several middleware (model routing + dynamic tools + logging) into reusable modules using the AgentMiddleware class.
Further reading
- How to use dynamic tools in agents — The official guide to dynamic tools
- How to use dynamic prompts — Dynamic system prompts in agents
- create_agent API Reference — The tools and prompt parameters
- StructuredTool API Reference — Creating tools programmatically
- MCP Integration Guide — Connecting MCP servers as dynamic tools
- Security Best Practices for Agents — Security in agents with tools
Module 4 — LangChain & LangGraph: From Chains to Agents