Skip to content

    Forward Deployed Engineer

    Tool Calling: One Read-Only Tool and Argument Validation

    Build the tool-calling loop for one read-only tool: the model proposes a call, code validates the arguments, runs it with the caller's access, returns it.

    By Published 8 min read

    • fde
    • tool-calling
    • function-calling
    • validation
    • pydantic
    • agents
    Share

    This lesson is part of Forward Deployed Engineer: Build LLM Features. Enroll for free, then sign in, to read it in full.

    What you'll learn: how the tool-calling loop works for one read-only tool, how to validate the model's arguments before anything runs, and how to turn a failure into an error the model can correct.

    At a glance

    Figure 1. The model proposes, the code validates and runs

    The Orchestrator receives a proposed tool call from the LLM provider, the Tool server validates and runs it against the catalog, and the result or an error goes back to the modelLLM provider to Orchestrator (tool call); Orchestrator to LLM provider (result); Orchestrator to Tool server (arguments); Tool server to Catalog (read); Tool server to Orchestrator (error text)LLM provider: proposes a callLLM providerproposes a callOrchestrator: the loop, validatesOrchestratorthe loop,validatesTool server: runs read-onlyTool serverruns read-onlyCatalog: caller's accessCatalogcaller's accesstool callresultargumentsreaderror text

    How to read this: the model only proposes; the Orchestrator and everything to its right is ordinary code that validates, reads and reports.

    • A tool call is a proposal. The model never runs anything.
    • The argument schema is the contract: code validates every call first.
    • A tool runs with the caller's access rights.
    • A validation failure goes back to the model as text it can act on.
    • Every loop has a limit.

    Scenario

    Composite scenario, not a real company. A Lumen Industrial Supply sales engineer asks the copilot about a specific product, and the answer needs its full record. The team gives the model one tool that reads a record by product code. In testing, the model sends vs-100 instead of LUM-VS-100. Nothing crashes and nothing is invented, because the code checks the argument first.

    What you will be able to do

    • Define a read-only tool with a Pydantic argument model and expose its schema.
    • Validate a call and return an error that names the failed rule without echoing the input.
    • Write a bounded loop that runs a tool and feeds the result back.
    • Map the capstone's loop onto tool_use and tool_result messages.

    Prerequisites

    Calling a provider gives the complete contract this loop calls. Structured outputs and schema validation explains the repair retry it reuses. For tool design and MCP, read Agents, tools and MCP.

    Core ideas

    Who does what in one tool call

    Predict first. The model proposes get_product_spec with the argument {"sku": "vs-100"}. The real code is LUM-VS-100. What does your code hand back: an exception, an empty record, or text? Does it contain vs-100?

    Answer

    Text, and it does not contain vs-100. The code returns Invalid arguments: sku: String should match pattern '^LUM-[A-Z]{2}-\d{3}

    #39;: the field and the rule, not the submitted value, which may hold private text.

    Step Who What happens
    1. Propose Model Names a tool and gives arguments
    2. Validate Code Checks the name against a registry and the arguments against a schema
    3. Authorize Code Uses the caller's visibility, so a hidden product looks like a missing one
    4. Run Code Reads the record; a failure becomes an error result
    5. Report Code Sends the result or error back; the model picks the next step

    The schema is the contract

    The capstone's tool takes one argument; the pattern is the defense against a malformed code:

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

    class GetProductSpecArgs(BaseModel):
        sku: str = Field(pattern=r"^LUM-[A-Z]{2}-\d{3}
    quot;
    )

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

    def get_product_spec(args: GetProductSpecArgs, ctx: ToolContext) -> dict[str, Any]:
        product = ctx.catalog.get(args.sku, ctx.visibilities)
        if product is None:  # same answer for "does not exist" and "not allowed"
            raise LookupError(f"No product {args.sku} found.")
        return {**_summary(product), "specs": product.specs}

    The same Pydantic model validates the call and produces the JSON Schema you show the model. A tool definition is a name, a description and an input_schema, which model_json_schema() returns:

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L5/m2_l5_one_tool.py

    def tool_definition(tool: Tool) -> dict[str, Any]:
        """A tool as the Messages API describes it: name, description and a JSON Schema for the input."""
        return {"name": tool.name, "description": tool.description, "input_schema": tool.args_model.model_json_schema()}

    The provider also documents a strict: true option that keeps calls matching the schema. Use it, and still validate in code: a schema checks shape, not who is asking, and limits such as limit at most 10 are yours to enforce.

    Validate, run, never raise

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L5/m2_l5_one_tool.py

    def instructive_error(err: ValidationError) -> str:
        """Where the problem is and which rule failed, so the model can correct it. Never the submitted value."""
        return "Invalid arguments: " + "; ".join(f"{'.'.join(map(str, e['loc']))}: {e['msg']}" for e in err.errors())
    
    
    def validated_call(tool: Tool, raw_args: dict[str, Any], ctx: ToolContext) -> ToolResult:
        try:
            args = tool.args_model.model_validate(raw_args)
        except ValidationError as err:
            return ToolResult(False, error=instructive_error(err))
        try:
            return ToolResult(True, data=tool.run(args, ctx))
        except Exception as err:  # a failing tool must not end the loop
            return ToolResult(False, error=f"{type(err).__name__}: {err}")

    validated_call has three outcomes and none raises: valid arguments produce data; invalid arguments produce text naming the field and rule; a failing tool (an unknown or hidden SKU, say) produces LookupError: .... The capstone's ToolRegistry.call names only the field; this version adds the rule, as the provider advises.

    NOTE

    The error text goes into the next prompt, so it is part of your prompt-injection surface. Build it from your own schema messages, never from user or document text.

    Two ways to carry the same loop

    The capstone asks the model, in plain text, to reply with a JSON decision, and validates it as PlanDecision:

    Verified: capstone/fde-product-discovery-copilot/app/llm/prompts.py

    def plan_messages(requirements: Requirements, observations: list[dict[str, Any]], tools: list[dict[str, str]]) -> list[Message]:
        system = (
            f"TASK: {TASK_PLAN}\n"
            "You plan a read-only product search. Choose one tool call per turn or finish. "
            'Reply with JSON: {"action": "tool"|"final", "tool": name, "args": {...}, "rationale": text}. '
            "Only the listed tools exist. Stop when you have enough to answer."
        )
        state = {"requirements": requirements.model_dump(), "tools": tools, "observations": observations}
        return [Message("system", system), Message("user", json.dumps(state, sort_keys=True))]

    That text protocol lets one loop run on the mock and on any provider. Native tool calling is the same exchange with structure added (provider documentation, checked 2026-11-02):

    Step The provider's message
    The call stop_reason is tool_use; a tool_use block holds an id, a name and an input
    The result A tool_result block with that tool_use_id, and is_error: true on failure
    The order The result immediately follows the call, before any text in that message

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L5/m2_l5_one_tool.py

    def tool_result_block(call_id: str, result: ToolResult) -> dict[str, Any]:
        """The `tool_result` content block that answers the `tool_use` block with this id."""
        if result.ok:
            return {"type": "tool_result", "tool_use_id": call_id, "content": json.dumps(result.data, sort_keys=True)}
        return {"type": "tool_result", "tool_use_id": call_id, "content": result.error, "is_error": True}

    The capstone's AnthropicProvider sends no tools parameter, so it uses the text protocol only; native tool use would change that adapter, which has never run live.

    Figure 2. One round trip in the provider's message shape

    sequenceDiagram accTitle: One tool call round trip with a validation branch accDescr: The Orchestrator sends the messages and the tool definition to the LLM provider. The provider replies with a tool call that has an id. The Orchestrator validates the arguments. If they are valid, the Tool server runs the call with the caller's visibility and the result goes back as a tool result with the same id. If they are invalid, an error result with the same id goes back instead. The provider then asks for another call or gives its final text. participant O as Orchestrator participant P as LLM provider participant T as Tool server O->>P: messages and tool definition P-->>O: tool_use with id c1 Note over O: validate the arguments alt arguments valid O->>T: run with caller visibility T-->>O: record O->>P: tool_result for c1 else arguments invalid O->>P: tool_result for c1 with is_error end P-->>O: another call, or the final text

    How to read this: the validation branch costs one more round trip, but the failing call never reaches the tool.

    Step through both branches.

    One tool call round trip (interactive when JavaScript is on)

    Arguments valid

    1. 1. The model asks. The reply names get_product_spec with the sku LUM-VS-100 and a call id. Nothing has run.
    2. 2. Validate. The registry knows the tool and the sku matches the pattern, so the call may proceed.
    3. 3. Run. The tool reads the record using the caller's visibility and returns the product and its specs.
    4. 4. Report. The record goes back under the same call id, and the model writes its next step or its answer.

    Arguments invalid

    1. 1. The model asks. The reply carries the sku vs-100, which is only generated text.
    2. 2. Validation fails. The pattern is not matched, so the tool is never called.
    3. 3. Error result. The text Invalid arguments: sku: String should match pattern goes back under the same call id, without the submitted value.
    4. 4. The model retries. The next reply carries a corrected sku, or the loop ends at its round limit.

    The loop, with a limit

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L5/m2_l5_one_tool.py

    def run_tool_loop(provider: LLMProvider, requirements: Requirements, tool: Tool, ctx: ToolContext, max_rounds: int = 4) -> LoopTrace:
        trace = LoopTrace("max_rounds")
        described = [{"name": tool.name, "description": tool.description}]
        for n in range(1, max_rounds + 1):
            try:
                decision = complete_structured(provider, plan_messages(requirements, trace.observations, described), PlanDecision)
            except StructuredOutputError:
                trace.status = "planner_error"
                return trace
            if decision.action == "final":
                trace.status = "completed"
                return trace
            if decision.action == "tool" and decision.tool == tool.name:
                result = validated_call(tool, decision.args, ctx)
            else:  # an unknown tool, or an action this loop does not offer
                result = ToolResult(False, error=f"Only the tool '{tool.name}' exists.")
            trace.rounds.append(Round(n, decision.args, result))
            observation: dict[str, Any] = {"tool": decision.tool or "", "args": decision.args, "ok": result.ok}
            observation.update({"data": result.data} if result.ok else {"error": result.error})
            trace.observations.append(observation)
        return trace
    • A malformed decision is repaired once by complete_structured, then ends the loop with planner_error.
    • A call to anything but the one registered tool becomes an error observation.
    • Every result goes into observations, which the next prompt carries.
    • max_rounds ends a model that never says final.

    In the interview: "How do you stop a model from calling a tool wrongly?" A good answer names four controls: a registry allow-list for the tool name, schema validation for arguments, authorization by the caller's identity, and loop limits. Write actions need approval.

    Worked example

    Take the capstone's pumps request: vibration monitoring on 40 pumps in a humid plant, budget about 30,000, OPC UA. With the mock, the planner makes six tool calls in nine model calls: one extraction, seven planning calls (six tools plus the final), one answer.

    Round Tool Arguments (abridged) Result
    1 search_catalog the filtered query below 2 products
    2 and 3 get_product_spec each of the 2 SKUs records
    4 and 5 check_compatibility each SKU with OPC UA not direct; gateways listed
    6 get_product_spec the first bridging gateway record

    The first call's arguments, from the mock planner:

    {"query": "vibration pump humid OPC UA", "limit": 5, "category": "sensors", "max_unit_price_eur": 750.0, "min_ip": 65}

    The unit-price cap of 750 is 30,000 divided by 40 pumps: the mock assumes one sensor per pump, which the customer never stated. A real model makes its own assumption, so the planner's arguments are logged and bounded (limit 1 to 10, min_ip 0 to 69).

    Now the one-tool loop. With the mock and a requirement that names a product, it runs one round. A scripted model that sends vs-100 and then LUM-VS-100 takes two. The error block after the first (the call id is made up):

    {"type": "tool_result", "tool_use_id": "call_2", "content": "Invalid arguments: sku: String should match pattern '^LUM-[A-Z]{2}-\\d{3}
    #x27;"
    , "is_error": true}

    Build it

    The code is in capstone/fde-product-discovery-copilot/lessons/M2-L5/. Step 1: test validated_call (above), including privacy.

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L5/test_m2_l5_one_tool.py

    def test_a_bad_sku_says_which_rule_failed_but_not_what_was_sent(ctx: ToolContext) -> None:
        result = validated_call(SPEC_TOOL, {"sku": "vs-100"}, ctx)
        assert not result.ok and result.error.startswith("Invalid arguments: sku: String should match pattern")
        assert "vs-100" not in result.error

    Step 2: prove the model can recover: a scripted provider errs once, then fixes it, and the second prompt must contain the first error.

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L5/test_m2_l5_one_tool.py

    def test_the_model_can_correct_itself_after_a_validation_error(ctx: ToolContext) -> None:
        provider = ScriptedProvider([decision("tool", sku="vs-100"), decision("tool", sku="LUM-VS-100"), decision("final")])
        trace = run_tool_loop(provider, Requirements(), SPEC_TOOL, ctx)
        assert trace.status == "completed" and [r.result.ok for r in trace.rounds] == [False, True]
        seen_by_model = provider.calls[1][-1].content  # the second prompt carries the first error
        assert "Invalid arguments: sku: String should match pattern" in seen_by_model

    Step 3: bound the loop. One test repeats a valid call and asserts it stops at max_rounds; another asks for a missing tool and asserts an error result. Run them:

    ./.venv/Scripts/python -m pytest lessons/M2-L5 -q

    Real output (timing varies):

    ..............                                                           [100%]
    14 passed in 4.79s

    The 14 tests include the exercise's reference solution; your first run shows 11.

    Exercise

    Pydantic ignores unknown keys by default (the documented default for extra). So {"sku": "LUM-VS-100", "currency": "USD"} passes, and the model thinks it narrowed a query it did not. Make the tool refuse arguments the schema does not name.

    Acceptance criteria: a test shows the default tool accepts the extra key; a test shows your tool returns an error naming currency but not USD; calls with only sku still work; nothing under app/ changes. Done when the three tests pass.

    Hint 1: where is the setting?

    A model-level Pydantic option: the extra setting.

    Hint 2: how do you avoid editing app/?

    Subclass GetProductSpecArgs in your lesson folder, set the option there, and build a new Tool around get_product_spec.

    Common mistakes

    • Trusting the arguments. The model's output is generated text; sku could be anything.
    • Echoing the input in the error. It reaches the prompt and logs. Name the field and rule, not the value.
    • Raising out of the loop. One bad call would lose the answer. Return an error result.
    • No limit. A model can repeat a call forever. Bound rounds, time and repeats.

    Knowledge check

    1. The model proposes a SKU that matches the pattern but belongs to a partner-only product, and the caller is public. What does the model see?

    The same error text as for a SKU that does not exist. Validation passes, but the tool runs with the caller's visibility and finds nothing; one answer for both hides that the product exists.

    2. Why return an error to the model instead of raising an exception when validation fails?

    An exception ends the request and loses the answer. An error result keeps the loop alive and tells the model what to fix; the round limit bounds retries.

    3. Does `strict: true` on a provider's tool definition remove the need for your own validation?

    No. It keeps the call matching the schema, but a schema cannot express who is asking or your business limits.

    4. In the provider's message format, what links a `tool_result` to the call it answers, and where must it sit?

    The tool_use_id in the result equals the id of the tool_use block. The result must follow the call immediately, before any text in that message, or the API errors.

    5. Your loop has no `max_rounds`. The model keeps requesting the same valid call. What is the failure and the fix?

    The loop never ends: the request hangs and every round adds to the bill. Bound the rounds, and as the capstone's WorkflowRunner does, detect exact repeats and set a time limit.

    You can now

    • Define a read-only tool whose Pydantic model is both validator and schema.
    • Return a useful error for a bad call without echoing the input.
    • Write a bounded loop that survives bad arguments and unknown tools.
    • Map the capstone's JSON decision onto tool_use and tool_result.

    Further reading

    Last verified: 2026-11-02

    Next

    Streaming and invalid output

    Enroll to keep reading

    Lesson 5 of 7 · Forward Deployed Engineer: Build LLM Features

    Build the tool-calling loop for one read-only tool: the model proposes a call, code validates the arguments, runs it with the caller's access, returns it.

    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: Build LLM Features, 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