Local AI Agents · Tool use and the agent loop · Lesson 3 of 4

Validation at every boundary and the approval gate

Two boundaries a tool loop must check independently — a tool call's arguments before dispatch, and a tool's result before it re-enters context — why passing both checks still isn't enough to let a write action through unattended, which is what an approval gate is for, and the module's final-assessment artifact built in practice — a complete tool-contract design covering schema, dispatch and failure handling.

Lesson · 20–30 minutes · Text-first

By the end, you can

  • Validate a tool call's arguments against its schema before dispatch, and explain why a validated call can still be wrong in a way schema validation cannot catch (LA-8).
  • Explain why a tool's own result needs a boundary check before it re-enters the model's context, distinct from validating the call that produced it (LA-8).
  • Design an approval gate for a write action, and explain what it catches that schema validation alone does not (LA-8).
  • Assemble a complete tool-contract design — schema, gated dispatch behavior and failure handling — as this module's final-assessment artifact (LA-8).

Before you start

This is Lesson 3 of 4 in Tool use and the agent loop, the module's closing concept lesson before the lab — and the lesson that carries this module's final-assessment contribution: its practice exercise produces the working tool-contract design (schema, dispatch and failure handling) the course's final assessment asks for, with the lab that follows serving as the runnable proof of the same pattern rather than the artifact itself. It assumes Lesson 1's tool schema and Lesson 2's dispatcher and result envelope. It also assumes two lessons from earlier in the course: Local-agent mental model's Autonomy boundaries lesson, which classified any proposed agent action as automatic, approval-required or out of scope, and its Tool permissions lesson, which treated a tool as a grant of authority rather than a convenience. This lesson brings both of those forward into a running tool loop specifically. It is also worth distinguishing from a different validation the model underneath module already covered: its Sampling, determinism and structured output lesson validated the model's own final *output* against a schema. This lesson validates the two boundaries around a *tool call* instead — its arguments going in, and its result coming back — which is separate territory the earlier lesson deliberately didn't cover.

Boundary one: validate the call's arguments before dispatch

Lesson 2's dispatcher already checks a call's arguments before running a handler — that check is this lesson's first boundary, named directly. A tool's schema (Lesson 1) is not decoration; it is a contract a call's actual arguments either satisfy or don't, and checking that by hand, field by field, is exactly what a real validator does at scale. Python's `jsonschema` library documents the pattern directly: its `validate()` function checks "an instance" against a schema and, on a failed check, raises a `ValidationError` — for example, checking `[2, 3, 4]` against `{"maxItems": 2}` fails with `ValidationError: [2, 3, 4] is too long`. The same idea applies to a tool call's arguments: check them against the tool's own declared schema before the handler ever runs, and treat a failed check as a normal, expected outcome — an error envelope, per Lesson 2 — not an exception that crashes the loop.

Here is the boundary's real value, not just its mechanism: a model does not reliably send arguments that match a schema's declared types, even when the schema is right there in the request. A small local model asked to call an `add(a, b)` tool with two numbers can, and in real testing for this course did, send `{"a": "17", "b": "25"}` — the correct values, but as strings, not numbers. Nothing about the request was malformed; the model simply didn't honor the declared `type: number`. A dispatcher that trusts arguments without checking would either crash on `"17" + "25"` behaving like string concatenation rather than addition (in a language where that's silently legal) or, worse, silently produce a wrong number no one would think to double-check. A dispatcher that validates first catches this the same way every time, as a clean, expected failure — not a surprise.

Boundary two: validate the result before it re-enters context

The model underneath module's System, user, tool and retrieved context lesson already established that a tool's result reads to the model exactly like any other text once it's back in the context window — there is nothing in the token stream that flags it as "data a tool produced" versus "an instruction to follow." That lesson's job was naming the risk. This lesson's job is the concrete boundary check it implies: before a tool's raw result is handed back to the model, check that it is the *shape* the loop expects — the right type, a bounded size, no unexpected wrapper — rather than trusting a handler's return value unconditionally.

This is a narrower, more mechanical check than treating tool content as fully untrusted in the fuller sense the course's later module on interoperable tools takes on — for a locally written, read-only tool like this module's calculator or word-counter, the realistic risk is a malformed or oversized result confusing the loop, not a maliciously crafted one. The result envelope from Lesson 2 already does most of this work: a caller checking `ok` before trusting `value` is itself a boundary check, applied consistently, rather than trusting whatever a handler happened to return.

Why a validated call still isn't enough for a write

Both boundaries above answer "is this the right shape." Neither one answers "is this the right thing to do." A `refund_customer(order_id, amount)` tool call with a perfectly valid `order_id` string and a perfectly valid `amount` number passes every schema check this lesson has covered so far — and can still refund the wrong customer, the wrong amount, or a customer who was never actually entitled to a refund at all, because none of that is something a JSON Schema type check can express.

This is exactly the gap Local-agent mental model's authority-boundary vocabulary was built for, and it applies to a tool loop's write actions directly. OWASP's current guidance on excessive agency states the mitigation in one line: "Utilise human-in-the-loop control to require a human to approve high-impact actions before they are taken," illustrated with a concrete example — an agent that drafts social media content should have "a user approval routine within the extension that implements the 'post' operation," so a person reviews the actual post before it goes anywhere. OpenAI's own guardrails guidance describes the same pause mechanically: a human-review step "pauses the run so a person or policy can approve or reject a sensitive action" before it takes effect. An **approval gate** is that pause, applied to a specific tool: instead of dispatching a write action's handler directly, the dispatcher returns a prepared, unexecuted description of what it would do — target, amount, exact effect — and a separate, explicit approval step has to happen before a second, distinct call actually performs it.

Put in this course's own terms: schema validation is the check that a call is well-formed. The approval gate is the check that a well-formed, correctly-typed, perfectly valid call is still the right action to actually take — the same automatic/approval-required/out-of-scope classification Autonomy boundaries introduced, now wired into the dispatcher itself rather than left as a standing instruction the model might or might not honor. This module's lab lesson stays entirely inside the automatic case on purpose — its tools are read-only, so nothing in it needs a gate — precisely so this lesson's design, not the lab's code, is what has to cover the write case.

A worked example: a refund tool with both boundaries applied

A support agent has a `refund_customer` tool: required arguments `order_id` (string) and `amount` (number, must be positive). A call arrives: `{"order_id": "ORD-4471", "amount": -50}`.

Boundary one catches this immediately: `amount` fails the schema's positivity check (a JSON Schema `minimum` constraint, or an equivalent hand-written check) before dispatch — an error envelope goes back, no refund attempted, and the model gets a chance to correct the call. Suppose the model corrects it to `{"order_id": "ORD-4471", "amount": 50}`. This now passes every schema check this lesson has described. It is exactly the case an approval gate exists for: the dispatcher does not call the real refund handler. It returns a prepared action — "refund ORD-4471 for $50, pending approval" — and a human reviews the order, the amount and the reasoning before a separate, explicit step actually issues the refund. Schema validation and the approval gate together cover what neither covers alone: one for shape, one for judgment.

Accessibility notes

This lesson is text-first, with no images, audio, video or downloadable artifacts. All code and JSON examples appear as plain inline or block text, readable and copyable by assistive technology and keyboard-only users. The practice exercise's model answer sits behind a native disclosure control that is reachable and operable by keyboard and correctly announced by screen readers. The knowledge check uses native radio-button inputs with a visible question and options, and posts its result to a live status region so assistive technology announces the outcome without a page reload.

Practice

Design the complete tool contract for a customer refund

A support agent has access to a refund_customer tool: required arguments order_id (string, must match an existing order) and amount (number, must be positive and no more than the order's original total). The tool calls the payment provider's API over the network to issue the refund. The agent is asked to process what it believes is a legitimate refund for order ORD-9910, an order whose original total was $40. This exercise builds this module's final-assessment artifact — a working tool-contract design covering schema, dispatch and failure handling.

  1. Write the JSON Schema this tool's arguments should satisfy, including how you would express the 'no more than the order's original total' constraint (note: JSON Schema alone may not fully express this — say what extra check, if any, has to happen outside the schema).
  2. A teammate shows you their dispatcher for this tool. In order, it: looks refund_customer up in the tool registry and returns an error envelope if the name is unknown; runs the handler inside a try/except that converts any exception into an error envelope; and returns the envelope either way. One boundary check this module requires is missing entirely — name it, and describe the concrete failure it would have caught if the model had sent amount as -40.
  3. The model calls refund_customer with {order_id: 'ORD-9910', amount: 40}. Does this call pass boundary one (argument validation) as you've defined it? Explain why or why not.
  4. Having passed boundary one, should this call be dispatched automatically, or does it need an approval gate? Justify your answer using this lesson's distinction between 'well-formed' and 'the right action.'
  5. Write what the dispatcher should return instead of directly issuing the refund, using this lesson's 'prepared, unexecuted description' idea.
  6. Complete the contract's failure handling, using the Dispatch, result envelopes and failure handling lesson's vocabulary: choose a timeout for the provider call, decide whether a failed call should be retried automatically, and state whether refund_customer is idempotent as designed — and if it is not, name the mechanism that lesson showed for making a retry safe.
  7. Name one thing a human reviewer should check before approving this specific refund that no schema check in this exercise could have caught.
Compare with a bounded first version

A schema can express order_id as a required string and amount as a required, positive number (e.g. {type: number, exclusiveMinimum: 0}); it cannot on its own express 'no more than this specific order's original total,' since that comparison depends on looking up the order, not just checking the shape of amount in isolation — that check has to happen as extra application logic inside the dispatcher (or the handler, before any write), not as part of the static JSON Schema. The teammate's dispatcher is missing boundary one entirely: argument validation against the schema before dispatch. Registry lookup and the handler's try/except are Lesson 2's registry and envelope pieces, but nothing between them checks the arguments' shape — so a call with amount -40 would sail straight into the handler and attempt a negative refund, exactly the malformed call the schema's positivity constraint exists to stop before any code that touches money runs. The call {order_id: 'ORD-9910', amount: 40} passes boundary one as defined: order_id is a string, amount is a positive number, and 40 does not exceed the order's $40 original total, so the extra application-level check also passes. Even having passed boundary one, this should not dispatch automatically — refund_customer moves money out of the business and is a textbook approval-required action under Autonomy boundaries' classification; passing schema and range checks only confirms the call is well-formed, not that this specific refund is the right thing to actually do, which is exactly this lesson's validated-but-still-wrong gap. The dispatcher should return a prepared, unexecuted description — something like {ok: true, pending_approval: true, action: 'refund_customer', order_id: 'ORD-9910', amount: 40, note: 'awaiting human approval'} — rather than an envelope reporting the refund as already issued. Failure handling: a timeout comfortably above the provider's normal response time (say 30 seconds, tuned to what the provider actually exhibits); no blind automatic retry, because refund_customer is not idempotent as designed — an ambiguous failure (request sent, no response received) leaves it unknown whether the refund already happened, and a retry could issue it twice; the Dispatch lesson's mechanism for making that retry safe is an idempotency key sent with the request, so the provider recognizes the repeat and returns the original result instead of refunding again — without one, the honest policy is to report the ambiguous failure for human follow-up rather than retry. Together, the schema, the gated dispatch behavior and these failure-handling choices are the complete tool-contract artifact this module contributes to the course's final assessment. One thing only a human can check here: whether this refund request is actually legitimate in the first place (was the order genuinely eligible, is this a duplicate of a refund already issued, does the reason given hold up) — nothing about a schema or a range check confirms the refund should happen at all, only that the numbers involved are well-formed.

Knowledge check

Try the idea

A write tool's call passes full JSON Schema validation — every required field present, every type correct, every numeric range satisfied. What does that actually confirm, according to this lesson?
Low-stakes practice only. This does not score, block progress or create a learner record.

Sources and limits

This lesson synthesises the sources below into a practical learning model. It is not a security standard, legal advice or a guarantee that any particular agent design is safe.

  1. Using jsonschema.validate()Read the Docs (jsonschema). Documents validate() checking data against a JSON Schema and raising a ValidationError on a failed check.
  2. LLM06:2025 Excessive AgencyOWASP Gen AI Security Project. States the human-in-the-loop mitigation for excessive agency: requiring a human to approve high-impact actions before they are taken, illustrated with a user-approval routine gating a 'post' action.
  3. Guardrails and human reviewOpenAI API documentation. Describes a human-review pause so a person or policy can approve or reject a sensitive action before it takes effect.