Module 2: Tool Use Fundamentals

7. Advanced Tool Schemas

Capsule overview

So far you've built tools with simple arguments: query: str, city: str, expression: str. That works for tutorials — but production tools need more. An e-commerce system needs tools that take full addresses with street, city, and zip code. An advanced search needs filters with predefined options. A multi-tenant system needs to inject user_id into every tool without the model ever seeing it.

This capsule covers five advanced patterns: nested Pydantic models for complex structures, enums to restrict options, optional fields with smart defaults, Field descriptions that guide the model, and InjectedToolArg to inject runtime context invisible to the LLM.

InjectedToolArg is the most important concept in this capsule. In production, every request has a user_id, a session_id, a database connection — context the tool needs but the LLM should never decide. InjectedToolArg solves this: the argument exists in the tool but never appears in the schema the model sees.


Nested Pydantic Models

The problem: flat arguments don't scale

@tool
def create_order(product_id: str, quantity: int, street: str, city: str, zip_code: str, country: str) -> str:
    """Create a purchase order."""
    ...

The model sees 6 arguments at the same level with no logical structure. If another tool also needs an address, you repeat every field.

The solution: nested models

from pydantic import BaseModel, Field
from langchain_core.tools import tool


class Address(BaseModel):
    """Complete shipping address."""
    street: str = Field(description="Street and number, e.g.: 'Av. Reforma 222'")
    city: str = Field(description="City, e.g.: 'Mexico City'")
    zip_code: str = Field(description="Zip code, e.g.: '06600'")
    country: str = Field(default="Mexico", description="Destination country")


class CreateOrderInput(BaseModel):
    """Input to create a purchase order."""
    product_id: str = Field(description="Product ID, e.g.: 'PROD-001'")
    quantity: int = Field(ge=1, le=100, description="Quantity to order (1-100)")
    shipping_address: Address = Field(description="Complete shipping address")


@tool(args_schema=CreateOrderInput)
def create_order(product_id: str, quantity: int, shipping_address: dict) -> str:
    """Create a purchase order with a shipping address."""
    addr = shipping_address
    return (
        f"Order created: {quantity}x {product_id} → "
        f"{addr['street']}, {addr['city']}, {addr['zip_code']}, {addr['country']}"
    )

The model generates structured JSON:

{
  "product_id": "PROD-001",
  "quantity": 2,
  "shipping_address": {
    "street": "Av. Reforma 222",
    "city": "Mexico City",
    "zip_code": "06600",
    "country": "Mexico"
  }
}

Example: SearchConfig with nested filters

from typing import Optional


class SearchFilters(BaseModel):
    """Filters to refine the search."""
    category: Optional[str] = Field(default=None, description="Category, e.g.: 'electronics'")
    min_price: Optional[float] = Field(default=None, ge=0, description="Minimum price in USD")
    max_price: Optional[float] = Field(default=None, ge=0, description="Maximum price in USD")


class SearchInput(BaseModel):
    """Complete input for product search."""
    query: str = Field(description="Search terms")
    filters: Optional[SearchFilters] = Field(default=None, description="Optional filters")
    limit: int = Field(default=10, ge=1, le=50, description="Maximum results (1-50)")


@tool(args_schema=SearchInput)
def search_products(query: str, filters: Optional[dict] = None, limit: int = 10) -> str:
    """Search products with advanced filters."""
    result = f"Searching '{query}' (limit={limit})"
    if filters:
        active = {k: v for k, v in filters.items() if v is not None}
        if active:
            result += f" | Filters: {active}"
    return result

The model can make simple calls ({"query": "laptop"}) or complex ones with every filter.

When to use nested models

SituationRecommendation
Tool with 2-3 simple argsFlat args — you don't need nesting
Args that form a logical group (address, config)Nested model for that group
Same group used across multiple toolsReusable model (DRY)
Tool with 6+ args at the same levelGroup them into nested models

Limitation: models handle 1-2 levels of nesting well. Beyond that, tool call quality drops. Rule of thumb: 2 levels max.


Enums as Arguments

The problem: open strings

@tool
def get_report(format: str) -> str:
    """Generate a report."""
    if format not in ("short", "detailed"):
        return f"Format '{format}' not recognized"
    ...

The model can pass "Short", "SHORT", "brief", "concise" — any variant.

The solution: Literal types

from typing import Literal


@tool
def get_report(
    topic: str,
    format: Literal["short", "detailed", "bullet_points"] = "short"
) -> str:
    """Generate a report on a topic.

    Args:
        topic: The report topic.
        format: Format — short (summary), detailed (complete), bullet_points (list).
    """
    reports = {
        "short": f"Summary of {topic}: key points in 2 paragraphs.",
        "detailed": f"Complete analysis of {topic}: context, data, conclusions.",
        "bullet_points": f"• Point 1 about {topic}\n• Point 2\n• Point 3",
    }
    return reports.get(format, reports["short"])

The schema includes the valid options. The model knows it can only pass those three values.

Enum classes for reusable options

When the options are used across multiple tools:

from enum import Enum


class Priority(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"
    critical = "critical"


@tool
def create_ticket(
    title: str,
    description: str,
    priority: Priority = Priority.medium
) -> str:
    """Create a support ticket.

    Args:
        title: Short ticket title.
        description: Detailed description.
        priority: Priority level (low, medium, high, critical).
    """
    return f"Ticket created: [{priority.value.upper()}] {title}"

str, Enum matters. Inheriting from str guarantees JSON serialization. Without str, some providers reject the schema.

Literal vs Enum

CriterionLiteralEnum
Simple options in one toolIdealOverkill
Options reused across multiple toolsRepetitionDefine once
SimplicitySimplerMore code

For most cases, Literal is enough.


Optional Arguments and Defaults

Why they matter

A search tool can have 10 parameters, but 80% of the time you only need query. If all of them were required, the model would invent values for each one.

from typing import Optional
from pydantic import Field


@tool
def search_articles(
    query: str = Field(description="Search terms"),
    language: Optional[str] = Field(default=None, description="ISO code: 'es', 'en'. None for all."),
    max_results: int = Field(default=10, ge=1, le=100, description="Maximum results"),
    include_summary: bool = Field(default=False, description="True to include a summary")
) -> str:
    """Search technical articles with optional filters."""
    result = f"Searching '{query}'"
    if language:
        result += f" (language: {language})"
    result += f" — max: {max_results}, summaries: {include_summary}"
    return result

The model reads the descriptions and compares them against the user's request: "Search articles about RAG" → only query. "Search 5 articles about RAG in Spanish" → query + language + max_results.

Smart defaults vs None

# Smart default: works without the argument
max_results: int = Field(default=10, description="Maximum results")

# None as default: the tool detects absence and adjusts
language: Optional[str] = Field(default=None, description="Language. None for all.")

None isn't "empty" — it's "not specified, apply default logic." That nuance enables smarter tools:

@tool
def search(query: str, language: Optional[str] = None) -> str:
    """Search articles."""
    if language is None:
        return search_all_languages(query)
    return search_by_language(query, language)

Schemas That Guide the Model

The schema isn't just validation — it's the manual the LLM reads to decide how to call the tool.

Vague schema vs precise schema

Vague — the model guesses:

@tool
def book_flight(origin: str, destination: str, date: str) -> str:
    """Book a flight."""
    return f"Flight {origin}{destination} on {date}"

The model can pass origin="Madrid" or "MAD" or "Barajas Airport".

Precise — the model follows instructions:

@tool
def book_flight(
    origin: str = Field(
        description="3-letter IATA code. Examples: 'MAD' (Madrid), 'MEX' (Mexico City), 'EZE' (Buenos Aires)"
    ),
    destination: str = Field(
        description="3-letter IATA code. Examples: 'BCN' (Barcelona), 'JFK' (New York)"
    ),
    date: str = Field(description="Date in YYYY-MM-DD format. Example: '2026-03-15'"),
    passengers: int = Field(default=1, ge=1, le=9, description="Number of passengers (1-9)"),
    cabin_class: Literal["economy", "business", "first"] = Field(
        default="economy", description="Cabin class"
    )
) -> str:
    """Book a flight between two airports. Use 3-letter IATA codes."""
    return f"Flight: {origin}{destination} | {date} | {passengers} pax | {cabin_class}"

Techniques for effective descriptions

1. Concrete examples: city: str = Field(description="City name. Examples: 'Madrid', 'Buenos Aires'")

2. Expected format: date: str = Field(description="ISO 8601 date: YYYY-MM-DD")

3. What the field does: verbose: bool = Field(default=False, description="True for full metadata. False for titles only.")

4. Explicit constraints: query: str = Field(min_length=1, max_length=500, description="2-5 keywords for best results.")

The docstring decides when, the descriptions decide how

@tool
def analyze_sentiment(
    text: str = Field(description="Text to analyze. Minimum 10 characters."),
    language: Literal["es", "en", "pt"] = Field(default="es", description="Language of the text")
) -> str:
    """Analyze the sentiment of a text (positive, negative, neutral).

    Use when the user asks about tone or sentiment.
    Do NOT use to translate or summarize.
    """
    return f"Sentiment of '{text[:30]}...' ({language}): Positive (0.85)"

InjectedToolArg

The problem: runtime context

In production, every tool needs context that doesn't come from the user: user_id, session_id, db_connection, api_key, tenant_id. If they show up in the schema, the model will try to fill them in — and it has no idea what to put there. That's a security risk.

The solution

InjectedToolArg marks an argument as "invisible to the model, injected at runtime":

from langchain_core.tools import tool, InjectedToolArg
from typing import Annotated


@tool
def get_user_orders(
    status: str,
    user_id: Annotated[str, InjectedToolArg]
) -> str:
    """Get the current user's orders filtered by status.

    Args:
        status: Order status ('pending', 'shipped', 'delivered').
    """
    return f"Orders for {user_id} with status '{status}': [ORD-001, ORD-003]"

The schema the model sees only contains status:

{
  "name": "get_user_orders",
  "parameters": {
    "properties": {
      "status": { "type": "string" }
    },
    "required": ["status"]
  }
}

user_id doesn't appear. The model doesn't know it exists.

Injection in the tool execution loop

from langchain_core.messages import HumanMessage, ToolMessage
from langchain.chat_models import init_chat_model

model = init_chat_model("openai:gpt-4.1-mini")
tools = [get_user_orders]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)


def agent_loop(user_input: str, user_id: str, max_iterations: int = 5) -> str:
    """Loop that injects user_id into tools."""
    messages = [HumanMessage(content=user_input)]

    for i in range(max_iterations):
        response = model_with_tools.invoke(messages)
        messages.append(response)

        if not response.tool_calls:
            return response.content or "No response"

        for tc in response.tool_calls:
            tc["args"]["user_id"] = user_id
            try:
                result = tools_by_name[tc["name"]].invoke(tc["args"])
            except Exception as e:
                result = f"Error: {e}"
            messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))

    return "Iteration limit reached"


print(agent_loop("What are my pending orders?", user_id="USR-42"))

The model asks for {"status": "pending"}. Your code adds "user_id": "USR-42" before executing. The tool receives both. The model never knew user_id existed.

Example: multi-tenant system with generic injection

@tool
def query_database(
    table: str,
    limit: int = 10,
    tenant_id: Annotated[str, InjectedToolArg] = "",
    db_url: Annotated[str, InjectedToolArg] = ""
) -> str:
    """Query data from a table.

    Args:
        table: Table name.
        limit: Maximum records (default 10).
    """
    return f"[{tenant_id}] SELECT * FROM {table} LIMIT {limit} (via {db_url})"


def inject_context(tool_call_args: dict, context: dict) -> dict:
    """Inject context arguments into the tool call args."""
    return {**tool_call_args, **context}


runtime_context = {
    "tenant_id": "TENANT-acme",
    "db_url": "postgresql://prod:5432/acme",
}

# In your loop:
for tc in response.tool_calls:
    args = inject_context(tc["args"], runtime_context)
    result = tools_by_name[tc["name"]].invoke(args)
    messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))

What to inject and what not to

Inject (InjectedToolArg)Do NOT inject (visible argument)
user_id, session_idData the user specifies
DB connections, API keysSearch parameters, filters
tenant_id in multi-tenantFormat or language options
Audit timestampsQuantities, names, queries

The rule: if the value comes from the system (auth, config, infrastructure), inject it. If it comes from the user, let the model pass it.


Comparison: Simple Schema vs Advanced Schema

AspectSimple schemaAdvanced schema
Argumentsquery: str, city: strNested models, Enums, Optional
DescriptionsMinimal or absentDetailed, with examples and format
ValidationBasic typesField(ge=1, le=100), constraints
OptionsOpen stringsLiteral["a", "b"] or Enum
Runtime contextNot supportedInjectedToolArg invisible to the LLM
Model precisionLow — guesses formatsHigh — follows the schema's instructions
SecuritySensitive data exposedSensitive data injected invisibly

Connection to the Project

In this module's project (capsule 08), you'll build an agent with 5 real external tools. These patterns apply directly:

  • Nested models: Your search tool can accept a SearchConfig with nested filters
  • Enums/Literal: Your weather tool can take units: Literal["celsius", "fahrenheit"]
  • Optional args: Every tool will have optional arguments with smart defaults
  • Descriptions: Every Field will have descriptions that guide the model
  • InjectedToolArg: If you implement the multi-user bonus, you'll inject user_id

In the evolving project (Modules 4-10):

  • M4: State machines inject state context into tools
  • M7 (MCP): MCP servers define their own schemas — nested models prepare you
  • M8 (Multi-agent): Each agent has tools with different schemas
  • M10 (Production): InjectedToolArg is essential — every request has a user_id and session context

Troubleshooting

Problem 1: The model doesn't fill nested objects correctly

Cause: Schema nested too deeply (3+ levels) or vague descriptions.

Solution: Reduce it to 2 levels max. If it persists, flatten it:

address: str = Field(
    description="Address on one line: 'Street, City, Zip, Country'. Example: 'Av. Reforma 222, Mexico City, 06600, Mexico'"
)

Problem 2: Enum/Literal ignored — values outside the options

Cause: The docstring contradicts the schema's options.

Solution: Keep the docstring and descriptions coherent. Add fallback validation:

@tool
def get_report(format: Literal["short", "detailed"] = "short") -> str:
    """Generate a report. Formats: short (summary), detailed (full analysis)."""
    if format not in ("short", "detailed"):
        format = "short"
    return generate(format)

Problem 3: InjectedToolArg shows up in the schema

Cause: Wrong syntax. You need Annotated[type, InjectedToolArg].

# CORRECT
user_id: Annotated[str, InjectedToolArg]

# INCORRECT
user_id: str = InjectedToolArg()

Check it: print(my_tool.get_input_schema().model_json_schema())

Problem 4: "Missing required argument" with Optional fields

Cause: Optional[str] with no default value — it's still required.

# INCORRECT — required despite being Optional
language: Optional[str] = Field(description="Language")

# CORRECT — Optional WITH a default
language: Optional[str] = Field(default=None, description="Language. None to auto-detect.")

Exercises

Exercise 1: Nested model for reservations (Easy)

Create reserve_restaurant with: restaurant_name: str, date: str, and a nested model Party with adults: int (1-20), children: int (default 0), and special_requests: Optional[str].

See solution
class Party(BaseModel):
    adults: int = Field(ge=1, le=20, description="Number of adults (1-20)")
    children: int = Field(default=0, ge=0, le=10, description="Number of children (0-10)")
    special_requests: Optional[str] = Field(default=None, description="Allergies, high chair, etc.")

class ReserveInput(BaseModel):
    restaurant_name: str = Field(description="Restaurant name")
    date: str = Field(description="Date/time: 'YYYY-MM-DD HH:MM'")
    party: Party = Field(description="Party information")

@tool(args_schema=ReserveInput)
def reserve_restaurant(restaurant_name: str, date: str, party: dict) -> str:
    """Book a table at a restaurant."""
    total = party.get("adults", 1) + party.get("children", 0)
    result = f"Reservation at {restaurant_name} for {date}{total} people"
    if party.get("special_requests"):
        result += f" (Note: {party['special_requests']})"
    return result

Exercise 2: Tool combining Literal and Optional (Easy)

Create translate_text with: text: str, target_language: Literal["es", "en", "fr", "pt", "de"], and formality: Optional[Literal["formal", "informal"]] (default None). How do you handle formality=None?

See solution
@tool
def translate_text(
    text: str = Field(description="Text to translate"),
    target_language: Literal["es", "en", "fr", "pt", "de"] = Field(
        description="Target language: es, en, fr, pt, de"
    ),
    formality: Optional[Literal["formal", "informal"]] = Field(
        default=None, description="Formality. None to auto-detect."
    )
) -> str:
    """Translate text into a target language."""
    formality_label = formality if formality else "auto"
    return f"Translation ({target_language}, {formality_label}): [{text[:40]}...]"

None is interpreted as "auto-detect" — the tool works with or without formality.

Exercise 3: InjectedToolArg for auditing (Medium)

Create delete_record with a visible record_id: str and two InjectedToolArgs: user_id and audit_reason. Verify that the schema only shows record_id. Implement the injection loop.

See solution
@tool
def delete_record(
    record_id: str,
    user_id: Annotated[str, InjectedToolArg] = "",
    audit_reason: Annotated[str, InjectedToolArg] = "user_request"
) -> str:
    """Delete a record by its ID.

    Args:
        record_id: Record ID, e.g.: 'REC-001'.
    """
    return f"Record {record_id} deleted by {user_id}. Reason: {audit_reason}."

# Check the schema — only record_id is visible
print(delete_record.get_input_schema().model_json_schema()["properties"].keys())
# → dict_keys(['record_id'])

# Loop with injection
CONTEXT = {"user_id": "admin-USR-42", "audit_reason": "cleanup_duplicates"}

def agent_with_injection(user_input: str, context: dict, max_iterations: int = 5) -> str:
    messages = [HumanMessage(content=user_input)]
    for i in range(max_iterations):
        response = model_with_tools.invoke(messages)
        messages.append(response)
        if not response.tool_calls:
            return response.content or "No response"
        for tc in response.tool_calls:
            args = {**tc["args"], **context}
            result = tools_by_name[tc["name"]].invoke(args)
            messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
    return "Iteration limit"

Exercise 4: Descriptions that eliminate errors (Medium)

You have analyze_code with code: str, language: str, analysis_type: str. The model frequently passes language="python3" and analysis_type="check errors". Redesign the schema.

See solution
@tool
def analyze_code(
    code: str = Field(description="Source code to analyze"),
    language: Literal["python", "javascript", "typescript", "go", "rust"] = Field(
        description="Exact language: python, javascript, typescript, go, rust"
    ),
    analysis_type: Literal["bugs", "performance", "security", "style"] = Field(
        default="bugs",
        description="'bugs' (logic errors), 'performance' (bottlenecks), 'security' (vulnerabilities), 'style' (conventions)"
    )
) -> str:
    """Analyze source code. Supports: Python, JS, TS, Go, Rust."""
    return f"Analysis [{analysis_type}] of {language} code: 3 issues found"

strLiteral for both fields. The descriptions list the exact options. The model can no longer invent variants.

Exercise 5: Complete system with every pattern (Hard)

Build a mini support system with 2 tools that combine every pattern:

  1. search_tickets: nested TicketFilters(status: Literal, priority: Optional[Literal]), InjectedToolArg for agent_id
  2. update_ticket: ticket_id, new_status: Literal, comment: Optional[str], InjectedToolArg for agent_id

Implement both tools and a loop with injected context.

See solution
class TicketFilters(BaseModel):
    status: Literal["open", "in_progress", "resolved", "closed"] = Field(default="open", description="Ticket status")
    priority: Optional[Literal["low", "medium", "high", "critical"]] = Field(default=None, description="Priority. None for all.")

class SearchTicketsInput(BaseModel):
    query: Optional[str] = Field(default=None, description="Free text in title/description")
    filters: Optional[TicketFilters] = Field(default=None, description="Search filters")
    limit: int = Field(default=10, ge=1, le=50, description="Maximum results")

@tool(args_schema=SearchTicketsInput)
def search_tickets(
    query: Optional[str] = None, filters: Optional[dict] = None,
    limit: int = 10, agent_id: Annotated[str, InjectedToolArg] = ""
) -> str:
    """Search support tickets with optional filters."""
    result = f"[Agent: {agent_id}] Search"
    if query: result += f" | query='{query}'"
    if filters: result += f" | filters={filters}"
    return result + f" | limit={limit} → [TKT-101, TKT-205, TKT-317]"

@tool
def update_ticket(
    ticket_id: str = Field(description="Ticket ID, e.g.: 'TKT-101'"),
    new_status: Literal["open", "in_progress", "resolved", "closed"] = Field(description="New status"),
    comment: Optional[str] = Field(default=None, description="Note about the change"),
    agent_id: Annotated[str, InjectedToolArg] = ""
) -> str:
    """Update the status of a support ticket."""
    result = f"[Agent: {agent_id}] Ticket {ticket_id}{new_status}"
    if comment: result += f" | Note: {comment}"
    return result

# Setup and loop
model = init_chat_model("openai:gpt-4.1-mini")
tools = [search_tickets, update_ticket]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)
CONTEXT = {"agent_id": "AGENT-support-42"}

def support_agent(user_input: str, context: dict, max_iterations: int = 5) -> str:
    messages = [HumanMessage(content=user_input)]
    for i in range(max_iterations):
        response = model_with_tools.invoke(messages)
        messages.append(response)
        if not response.tool_calls:
            return response.content or "No response"
        for tc in response.tool_calls:
            args = {**tc["args"], **context}
            result = tools_by_name[tc["name"]].invoke(args)
            messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
    return "Iteration limit"

print(support_agent("Find open critical tickets and close TKT-101 with the note 'resolved in prod'", CONTEXT))

It combines: nested model, Literal, Optional, InjectedToolArg, descriptions, and a loop with injection.


Summary

In this capsule you learned:

  • Nested Pydantic models group related arguments — 2 levels of depth max
  • Literal and Enum restrict options to valid values, eliminating format errors
  • Optional with defaults make tools flexible: the model only passes what it needs
  • Field descriptions are direct instructions to the model — examples, formats, and constraints improve precision
  • InjectedToolArg injects runtime context (user_id, db_url) invisible to the model — the pattern that separates demos from production

Next capsule: Project — Agent with 5 Real External Tools. You'll apply the whole module: schemas, execution loop, built-in tools, error handling, and advanced schemas to build a complete agent.


Additional Resources

  1. Pydantic Models — Official Documentation — BaseModel, Field, nested models, validation
  2. LangChain Custom Tools — Custom schemas with @tool and args_schema
  3. LangChain InjectedToolArg — Runtime argument injection
  4. Pydantic Field Constraints — ge, le, min_length, description
  5. Python Enum — Enum and str Enum
  6. Python Typing — Literal, Optional, Annotated — Advanced types
  7. OpenAI Function Calling — How OpenAI processes tool schemas
  8. Anthropic Tool Use — How Claude processes schemas and nested objects