Tool Contracts and Validators: Move the Rule Out of the Prompt

A confident model will run a destructive call on impossible input unless a deterministic check sits between the decision and the side effect; this is how you build that check, twice.

Rick Hightower

Cover image for “Tool Contracts and Validators: Move the Rule Out of the Prompt” by Rick Hightower

Your agent will run a destructive call on impossible input as long as the arguments parse. The fix is not a smarter model. It is a deterministic check that sits between the decision and the side effect, and you can build it in both the Claude Agent SDK and LangChain Deep Agents.

Your agent reported success on a booking that never existed. The model was not broken.

In this article: You will learn why a well-formed tool call can still be the wrong action, and how to stop it. We cover the plan-step contract, the three layers of validation (structural, semantic, and policy), and the single most load-bearing control in any agent: the action boundary. You will build the same shared validator twice, once as a Claude Agent SDK PreToolUse hook and once as a LangChain Deep Agents wrap_tool_call middleware, then watch a blocked call turn into a recovery instead of a failure.

A user types "March 32nd". The model fills the date field with a string that is structurally valid and semantically impossible. The booking tool runs against a phantom itinerary. The agent reports success. Nothing in the loop ever asked whether the action should happen.

That is a contract failure, and it has a specific shape. The model had decided to act. The reasoning was plausible. The arguments parsed. And the action was wrong. No stronger model fixes this, because the model is not malfunctioning. It is doing exactly what models do: producing confident, well-formed output. What was missing is a deterministic check that runs between the decision and the side effect.

This article builds that check. The principle underneath it fits in one sentence:

Instructions get followed most of the time. Contracts get enforced every time.

The plan-step contract

Every step an agent takes makes a claim: "I will produce output X." Without something that checks X deterministically before the action fires, you have a suggestion, not a guarantee. The plan-step contract formalizes it. A step declares a precondition it needs to run and a postcondition it promises to produce, and the harness checks both.

The design rule that makes this work lives upstream of the validator. An expected output is only useful if a non-LLM validator can evaluate it deterministically. If you cannot write code, without calling a model, that returns True or False for the output, the output is not verifiable. A practical heuristic: if a junior developer can write a unit test for the output without reading any natural language, the output is checkable. If the test needs human judgment about meaning, it is not.

That pushes you to design tool inputs and outputs as typed, bounded values rather than free prose. cabin_class is an enum of four values, not a string the model fills with "eco" or "coach". price_usd lives in a range, because a schema that accepts any float accepts -1.0 and 999999.99 with equal enthusiasm. departure_date must be in the future, which JSON Schema cannot express but a one-line function can.

Validators come in three layers, cheapest first:

  1. Structural. Wrong type, missing field, extra key. Caught by schema-forced output.
  2. Semantic. Well-formed but wrong value. The 1995 departure date. Caught by code.
  3. Policy. Valid value that violates a business rule. A $750 refund against a $500 cap. Caught by a guard at the action boundary.

Three validator layers, cheapest first: structural catches wrong shape, semantic catches wrong value, and policy catches a valid value that breaks a business rule.

We build all three across the two frameworks.

Layer 1: structural validation at the sampler

The first job is to eliminate the wrong-shape class of errors before anything else runs: wrong types, missing fields, extra keys. In the Claude Agent SDK you declare the shape on the tool and let the sampler enforce it. strict=True turns on grammar-constrained sampling, so a malformed tool input cannot even be generated, and tool_choice can force the model to call the one tool a locked-down step requires.

from claude_agent_sdk import tool, ClaudeAgentOptions

@tool(
    "book_flight",
    "Book a confirmed flight for the passenger",
    {
        "flight_id": str,
        "passenger_name": str,
        "cabin_class": {"type": "string",                 # ① closed vocabulary
            "enum": ["economy", "premium_economy", "business", "first"]},
        "price_usd": {"type": "number",                   # ② bounded range
            "minimum": 0, "maximum": 50000},
    },
)
async def book_flight(args: dict) -> dict:
    confirmation = flights_api.book(**args)
    return {"content": [{"type": "text", "text": confirmation}]}

options = ClaudeAgentOptions(
    tool_choice={"type": "tool", "name": "book_flight"},  # ③ force this tool
    strict=True,                                          # ④ schema-valid by construction
)

① The enum closes cabin_class to four legal values. ② The range makes price_usd's bounds part of the schema. ③ tool_choice requires the booking step to call book_flight and nothing else. ④ strict=True constrains sampling so malformed inputs are never produced, rather than caught after the fact.

Note: The full extracted listing at code/harness_engineering/part-2-tool-contracts-and-validators/listings/01-book-flight-tool.py shows the booking backend stub elided here.

In Deep Agents the same structural intent rides on the tool's typed signature, and you can override tool_choice for a locked step with a @wrap_model_call middleware. The typed parameters are the contract; the framework rejects calls that do not match. Structural validation is cheap and catches a large class of errors, but it does not catch the 1995 date. That value is well-formed. We need the next layer.

Layers 2 and 3: the action boundary

The semantic and policy layers share one place to live: a guard that fires after the model has chosen its arguments and before the tool runs. This is the action boundary, and it is the most load-bearing control in the harness. A business rule here is guaranteed. The same rule in a prompt is aspirational.

The action boundary: the model chooses arguments, a deterministic check runs, and the tool fires only if the contract is satisfied; otherwise the call is denied and the error returns to the model.

Start with the deterministic check itself, framework-agnostic, the kind of function a junior developer can unit-test:

from datetime import date

def validate_booking(args: dict) -> str | None:
    """Return an error string if the booking violates a contract, else None."""
    try:
        depart = date.fromisoformat(args["date"])         # ① rejects "March 32nd"
    except (ValueError, KeyError):
        return f"date {args.get('date')!r} is not a valid YYYY-MM-DD date."
    if depart < date.today():                             # ② semantic invariant
        return f"departure {depart} is in the past; bookings must be future-dated."
    if args.get("price_usd", 0) > 50_000:                 # ③ policy cap
        return "price exceeds the $50,000 auto-approve cap; escalate for approval."
    return None                                           # ④ contract satisfied

date.fromisoformat rejects the impossible date that the schema waved through. ② The past-date check is a semantic invariant the schema cannot express. ③ The cap is a policy rule. ④ Returning None is the allow path. The same function drives both frameworks; only the wiring differs.

Note: The full extracted listing at code/harness_engineering/part-2-tool-contracts-and-validators/listings/02-validate-booking.py shows the runnable standalone validator.

One shared validator, two frameworks: the same validate_booking function drives a Claude Agent SDK PreToolUse hook and a LangChain Deep Agents middleware, producing the same guarantee.

Claude Agent SDK: a PreToolUse hook that denies

The SDK fires a PreToolUse hook after the model creates the tool input and before the call is processed. Return a deny decision and the tool never runs. The reason you return flows back to the model as the tool result, so write it in language the model can act on.

from claude_agent_sdk import ClaudeAgentOptions, HookMatcher

async def enforce_booking_contract(input_data, tool_use_id, context) -> dict:
    if input_data.tool_name != "book_flight":            # ① keep the hook narrow
        return {}                                        # allow: not our tool
    error = validate_booking(input_data.tool_input)      # ② run the contract
    if error:
        return {"hookSpecificOutput": {
            "permissionDecision": "deny",                # ③ block absolutely
            "permissionDecisionReason": error,           # ④ model-readable feedback
        }}
    return {}                                            # allow: contract satisfied

options = ClaudeAgentOptions(
    hooks={"PreToolUse": [
        HookMatcher(matcher="book_flight", hooks=[enforce_booking_contract])
    ]},
)

① The hook checks only the tool the policy applies to. ② It delegates to the shared validator. ③ A deny is absolute: when multiple hooks apply, deny wins over ask wins over allow. ④ The denial reason becomes the model's next tool result, which turns a block into a correction signal rather than a dead end.

Note: The full extracted listing at code/harness_engineering/part-2-tool-contracts-and-validators/listings/03-pretooluse-hook.py shows the shared-validator import elided here.

LangChain Deep Agents: a wrap_tool_call middleware

Deep Agents exposes the same interception point as @wrap_tool_call middleware. The decorator receives the request (tool name and input) and a handler (the tool itself). Return an error before calling handler to deny; call handler(request) to allow.

from langchain.agents.middleware import wrap_tool_call
from deepagents import create_deep_agent

@wrap_tool_call
def enforce_booking_contract(request, handler):
    if request.tool_name == "book_flight":
        error = validate_booking(request.tool_input)     # ① same shared validator
        if error:
            return {"error": error, "is_error": True}    # ② short-circuit: tool skipped
    return handler(request)                              # ③ allow: tool runs

agent = create_deep_agent(
    model="anthropic:claude-sonnet-4-6",
    tools=[search_flights, book_flight],
    middleware=[enforce_booking_contract],               # ④ boundary on every tool call
)

① The middleware reuses validate_booking, so the rule lives in exactly one place. ② Returning an error dict before handler runs is the deny path; the tool never executes. ③ handler(request) is the allow path. ④ The middleware wraps every tool call, giving you one boundary for the whole agent.

Note: The full extracted listing at code/harness_engineering/part-2-tool-contracts-and-validators/listings/04-wrap-tool-call-middleware.py shows the shared-validator import and tool stubs elided here.

Trigger it, then inspect the recovery

Run either agent against "Rebook this customer for March 32nd" and the boundary fires. validate_booking returns an error, the call is denied, and the model receives the reason as its next observation:

tool_result: date 'March 32nd' is not a valid YYYY-MM-DD date.
assistant: That date isn't valid. Did you mean March 3rd, or a date in April?

This is the part worth pausing on. A blocked call is not a failure of the run; it is feedback that drives the next attempt. A specific message, "not a valid date," does the work that a generic "try again" cannot. The same pattern catches the $750 refund: the boundary denies, returns "exceeds the $500 cap, escalate to a manager," and the model escalates or offers a partial refund within cap. The harness did not make the model smarter. It made one rule non-negotiable and handed the model a path to recover.

A denied call becomes a recovery: the boundary rejects the impossible date with a specific reason, the model asks a clarifying question, and the corrected booking succeeds.

Why the boundary beats the prompt

Kyle Redelinghuys put the distinction cleanly: tell Claude Code in your config not to modify .env files and it will probably listen; set up a PreToolUse hook that blocks writes to .env files and it will always block them. For anyone in a regulated environment, the gap between probably and always is everything.

Probably versus always: a rule in the prompt is usually obeyed but not guaranteed, while the same rule at the boundary runs in code on every call and is non-negotiable.

That gap is the whole argument for the action boundary. Prompts persuade. Hooks enforce. When a contract has to hold every time, it cannot live in text the model is free to reinterpret. It lives in code the model cannot argue with.

This is also the same primitive as the edit-time linter that helped triple SWE-agent's score: a pre-hook that runs a syntax check the moment an edit is issued, blocks the edit if the file would not parse, and returns the error with line context so the agent fixes it next turn. Refund cap, booking date, syntax check: one contract shape, three actions.

Do this today

  • Write one validate_booking-style function for the riskiest tool in your agent, and make sure it returns a specific, model-readable error string rather than just raising.
  • Wire that function into both a Claude Agent SDK PreToolUse hook and, if you run Deep Agents, a wrap_tool_call middleware. Confirm the same function powers both.
  • Send the impossible input and verify the denial reason appears in the model's next turn, and that the agent recovers instead of stalling.
  • Add a second invariant (for example, a return_date that must be after departure_date) to the shared function, and watch both frameworks inherit it from one change.
  • Split your policy cap into a hard deny above one threshold and an ask in a middle band. You have just discovered human-in-the-loop, the subject of a later part.

The first rule you move out of the prompt

The action boundary is the first rule you move out of the prompt and into the runtime, and it is the one that pays for itself fastest. Two frameworks, one shared validator, one guarantee: a destructive call cannot fire on input that fails a deterministic check.

But a denied booking still assumes the agent remembers why it was rebooking in the first place. In a naked agent, it does not: the session evaporates the moment the run ends. The next step is to give the harness a memory you can reason about, in the Claude Agent SDK and in Deep Agents, so the agent carries context across turns instead of starting from zero every time.

Prompts persuade. Contracts get enforced every time. Once you have felt the difference between probably and always, you will not ship an agent without a boundary again.


Code note: pin one claude-agent-sdk and one deepagents / langchain version and run every listing against it; both APIs drift between releases, and middleware names in particular move. Model identifiers are placeholders to set at publish time. The in-article listings are the focused versions; the companion repo carries the runnable end-to-end files and the full three-layer validator stack.