Skip to content

    Forward Deployed Engineer

    Tool Definitions and Validated Arguments in Python

    Define two catalog tools with Pydantic schemas, validate every model-proposed argument in code, and return an error the model can act on instead of crashing.

    By Published 8 min read

    Share

    This lesson is part of Forward Deployed Engineer: Agents, Tools and Guardrails. Enroll for free, then sign in, to read it in full.

    What you'll learn: what a tool definition contains, how a Pydantic model turns into the schema the model sees, how the application validates what the model sends back, and what an error result should and should not say.

    At a glance

    Figure 1. One proposed tool call, checked before anything runs

    sequenceDiagram accTitle: Validating the arguments of one proposed tool call accDescr: The model gateway returns a proposed tool name and arguments to the orchestrator. The orchestrator asks the registry to call the tool. The registry looks up the tool name; an unknown name returns an error result. Otherwise it validates the arguments with the tool's Pydantic model; invalid arguments return an error result naming the fields. Only valid arguments reach the tool function, whose result or failure also comes back as a result object. participant M as Model gateway participant O as Orchestrator participant R as Registry participant V as Args model participant T as Tool function M-->>O: tool name and raw arguments, untrusted O->>R: call(name, raw arguments, context) alt name not registered R-->>O: error result, unknown tool else name registered R->>V: model_validate(raw arguments) alt arguments invalid V-->>R: ValidationError R-->>O: error result, field names only else arguments valid V-->>R: typed arguments R->>T: run(typed arguments, context) T-->>R: data, or an exception R-->>O: result object, never an exception end end

    How to read this: the model's reply enters at the top as untrusted text, and only the innermost branch ever runs your tool function.

    • A tool is a name, a description, an input schema and a function. The model sees the first three.
    • The schema is written once, as a Pydantic model, and the JSON Schema for the model is generated from it.
    • The application validates the arguments again on receipt. The model's output is a proposal.
    • Failures of every kind come back as a result object, so one bad call never crashes the loop.

    Scenario

    Composite scenario, not a real company. At Lumen Industrial Supply, a fictional distributor, a pilot of the product assistant logs every tool call the model proposes. A reviewer reads a sample and finds three kinds of bad call: a SKU written in lower case, a result limit of 99 where the catalog allows 10, and a call to a tool that does not exist.

    In an illustrative test of 200 proposed calls, 9 have a wrong shape and 3 name an unknown tool. Nothing may crash, and the reviewer wants the rate of each kind.

    What you will be able to do

    • Write a tool as a Pydantic argument model plus a function, and register it.
    • Print the JSON Schema the model would receive for a tool.
    • Predict whether a given malformed call is rejected, coerced or silently trimmed.
    • Return an error that names the field and the rule without echoing the submitted value.

    Prerequisites

    Core ideas

    A tool has four parts

    Part Example Seen by the model Written by
    Name get_product_spec Yes You
    Description "Read one product record by SKU." Yes You
    Input schema sku must match a pattern Yes, as JSON Schema Generated from the Pydantic model
    Function Reads the catalog, returns a dict No You

    The Claude API documentation lists the same parts: a name, a description and an input_schema that is "a JSON Schema object defining the expected parameters for the tool" (Define tools). It also says detailed descriptions are the most important factor in tool performance and suggests at least three or four sentences per tool.

    The capstone's descriptions are one short sentence because its default mock provider never reads them; with a real model, write longer ones. This is what the model would receive for one tool (real output from the lesson code):

    {
      "name": "get_product_spec",
      "description": "Read one product record by SKU.",
      "input_schema": {
        "properties": {
          "sku": {
            "pattern": "^LUM-[A-Z]{2}-\\d{3}
    quot;
    , "title": "Sku", "type": "string" } }, "required": [ "sku" ], "title": "GetProductSpecArgs", "type": "object" } }

    The registry joins the parts

    Figure 2. The tool registry as entities

    erDiagram accTitle: Entities of the tool registry accDescr: A registry holds many tools. Each tool has exactly one argument model and one tool function. A tool function receives a tool context that holds the catalog and the visibilities the caller may see. Each call to a tool produces exactly one tool result, which is either data or an error message. REGISTRY ||--o{ TOOL : holds TOOL ||--|| ARGS_MODEL : "validates with" TOOL ||--|| TOOL_FUNCTION : runs TOOL_FUNCTION }o--|| TOOL_CONTEXT : receives TOOL ||--o{ TOOL_RESULT : produces TOOL { string name string description bool read_only } ARGS_MODEL { string fields string constraints } TOOL_CONTEXT { object catalog set visibilities } TOOL_RESULT { bool ok object data string error }

    How to read this: a tool never touches the catalog directly; it receives a context built by the application.

    Two design choices are worth copying:

    • The registry refuses a tool that is not read-only, so a write action cannot be registered by accident.
    • call never raises: unknown names, bad arguments and tool failures are all values.

    Validate on receipt, even with a strict mode

    Pydantic's model_validate "will raise a ValidationError exception whenever it finds an error in the data it's validating" (Pydantic models). The Claude API also offers strict: true on a tool definition, which it describes as guaranteeing that tool inputs match the schema (Strict tool use).

    The default here is to validate anyway, for these reasons:

    • Schema-valid is not business-valid: LUM-XX-000 matches the pattern and still does not exist.
    • The same registry may serve a provider or a test double that offers no strict mode.
    • Validation costs very little next to a model call.
    • Drop it only where the provider guarantee is documented and tested, and the tool is read-only.

    Spot the flaw. What is wrong with this tool dispatcher? (Pseudocode.)

    Pseudocode.

    def dispatch(name, raw_args):
        tool = TOOLS[name]
        result = tool.function(raw_args["sku"], raw_args.get("limit", 5))
        return {"ok": True, "data": result}
    Answer

    Three defects. TOOLS[name] raises KeyError for an unknown name, so the loop crashes. Nothing validates sku or limit: a limit of 99 reaches the function, and a missing sku raises KeyError in the dispatcher itself. And an exception inside the function also escapes. Fix: look up with a default, validate with the tool's model, and wrap the call so every outcome is a result object.

    Lax coercion and extra keys

    By default Pydantic works in lax mode: it "will attempt to coerce values to the desired type when possible", so the string '123' becomes the integer 123 (Strict mode). Extra keys are ignored by default; with extra='forbid' they raise an error (Pydantic models).

    Proposed arguments Result in the capstone Why
    {"limit": "2"} Accepted, limit is 2 Lax mode converts the string
    {"limit": 5.5} Rejected Not a whole number
    {"sku": "lum-vs-100"} Rejected Pattern requires upper case
    {"sku": "LUM-VS-100", "role": "admin"} Accepted, role dropped Extra keys are ignored

    Dropping an unknown key is safe, because the function never sees it. But a model that invents a role argument has misunderstood the tool; a strict variant makes that visible.

    Worked example

    The reviewer from the scenario wants a rate per kind of rejection. Assumptions (illustrative): 200 proposed calls in a test; 9 rejected for argument shape; 3 rejected for an unknown tool name; the rest ran.

    Outcome Calls Rate
    Rejected: argument shape 9 9 / 200 = 4.5 percent
    Rejected: unknown tool 3 3 / 200 = 1.5 percent
    Ran 188 188 / 200 = 94.0 percent

    Each rejection is a result with a message, so you can count them without parsing logs. A shape rate that climbs after a description change tells you the new description confuses the model.

    At the customer: asked "how do you know the AI cannot call something it should not?", answer that the registry holds the only tools that exist, every call is validated, and the rejected-call rate is a dashboard number.

    Build it

    The registry's call method is the code in Figure 1.

    Verified: capstone/fde-product-discovery-copilot/app/workflow/tools.py

        def call(self, name: str, raw_args: dict[str, Any], ctx: ToolContext) -> ToolResult:
            """Never raises: bad names, bad arguments and tool failures all come back as an error result."""
            tool = self._tools.get(name)
            if tool is None:
                return ToolResult(False, error=f"Unknown tool '{name}'.")
            try:
                args = tool.args_model.model_validate(raw_args)
            except ValidationError as err:
                return ToolResult(False, error="Invalid arguments: " + ", ".join(".".join(map(str, e["loc"])) for e in err.errors()))
            try:
                return ToolResult(True, data=tool.run(args, ctx))
            except Exception as err:  # a failing tool must not crash the loop
                return ToolResult(False, error=f"{type(err).__name__}: {err}")

    The error names the failing fields but not their values or the rule. The lesson code adds the error type, still without the value: Pydantic's errors() accepts include_input=False (ValidationError).

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L2/m4_l2_tool_args.py

    def rejection_report(model: type[BaseModel], raw_args: dict[str, Any]) -> list[str]:
        """Which field failed and why, without echoing the submitted value (it may hold private text)."""
        try:
            model.model_validate(raw_args)
        except ValidationError as err:
            return [f"{'.'.join(map(str, e['loc'])) or 'arguments'}: {e['type']}" for e in err.errors(include_input=False, include_url=False)]
        return []

    The tests assert each row of the table above.

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L2/test_m4_l2_tool_args.py

    def test_the_report_names_fields_and_error_types_but_never_the_value() -> None:
        report = rejection_report(SearchCatalogArgs, {"limit": 99, "category": "toasters", "query": "secret " * 40})
        assert report == ["query: string_too_long", "category: literal_error", "limit: less_than_equal"]
        assert not any("secret" in line for line in report)
        assert rejection_report(GetProductSpecArgs, {"sku": "LUM-VS-100"}) == []
    NOTE

    The capstone's AnthropicProvider.complete has never been run against the live API, and the default MockProvider is a stand-in. These tests prove the validation path, not how a real model behaves.

    Exercise

    Add a fourth read-only tool, compare_products, that takes two or three SKUs and returns price, ingress rating and protocols for each. Reject a single SKU, four SKUs, a duplicate and a malformed SKU. A product the caller may not see must give the same answer as a product that does not exist.

    Acceptance criteria: the tool is registered through ToolRegistry.register; invalid calls return Invalid arguments: skus; a partner-only SKU returns LookupError: No product LUM-VS-900 found. for a public caller; your tests cover each of those cases. Done when ./.venv/Scripts/python -m pytest lessons/M4-L2 -q passes with your file added and the three existing tools still behave as before.

    Hint 1: where do the count limits go?

    A list field accepts min_length and max_length in Field. Keep the "all different" rule in a validator.

    Hint 2: how do I hide a partner-only product?

    Call ctx.catalog.get(sku, ctx.visibilities), which already applies the caller's visibility, and raise the same LookupError for None that get_product_spec raises.

    Common mistakes

    • Validating in the prompt ("the SKU must look like LUM-XX-000") and not in code.
    • Echoing the rejected value in the error, which can copy private text into logs and back into the model's context.
    • Letting a tool exception escape, so one bad call ends the whole run.
    • Registering a write tool beside the read tools instead of putting it behind an approval gate.

    Knowledge check

    1. Why does `call` return a result object instead of raising?

    The loop must continue after a bad call, and the model needs to read what went wrong. A result object makes the failure data you can count and show to the model.

    2. `{"limit": "2"}` is accepted and `{"limit": 5.5}` is rejected. Why?

    Lax mode converts a numeric string to an integer when it can, and 5.5 is not a whole number. To reject the string, use a strict model.

    3. A call includes an extra key `role: admin`. What happens by default, and why does it matter?

    The key is ignored and nothing breaks, but it signals that the model misunderstood the tool. extra="forbid" rejects the call and puts the signal into your rejected-call count.

    4. If the provider guarantees schema-valid inputs, why still validate?

    A valid shape is not a valid request: a well-formed SKU can still not exist or not be visible to the caller. Validation is cheap and removes a dependency on one provider's guarantee.

    5. What should an error message to the model contain?

    The field that failed and the kind of rule it broke, so the model can correct the call. Not the submitted value or a stack trace, which can leak private text.

    You can now

    • Describe the four parts of a tool and which of them the model sees.
    • Generate the JSON Schema for a tool from a Pydantic model.
    • Predict rejection, coercion or trimming for a malformed call.
    • Write a read-only tool and register it with tests.

    Further reading

    Last verified: 2026-11-02

    Next

    Permissions the application enforces

    Enroll to keep reading

    Lesson 2 of 8 · Forward Deployed Engineer: Agents, Tools and Guardrails

    Define two catalog tools with Pydantic schemas, validate every model-proposed argument in code, and return an error the model can act on instead of crashing.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Agents, Tools and Guardrails, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments