BXUP Track progress & test yourself — free
Software Engineering · Playbook · Advanced · ~4 min read
SOFTWARE ENGINEERING · PLAYBOOK · ADVANCED

Tool-Calling Backend Feature: Safe Internal Operations, Validated Arguments, Bounded Loops

Tool calling turns a model from a text generator into something that acts on your systems — which means the engineering problem shifts from prompt quality to blast radius.

Tier: Advanced · Workflow area: Building LLM Features

This playbook exposes two or three safe internal operations as tools behind a bounded agentic loop: strict argument schemas, server-side validation the model cannot talk its way past, parallel-call handling, explicit error paths, and an iteration cap so the loop always terminates.

When to use

  • A feature needs the model to decide which internal operation to invoke: "look up this order, then check refund eligibility".
  • You are replacing a brittle intent-classifier + if/else chain with model-routed operations.
  • You want an internal support/ops copilot over existing service APIs.

Not for single-shot extraction (pb-44) — tool calling adds a loop, latency, and risk; earn it with a real multi-step need.

Prerequisites

  • pb-44 shipped: schema discipline, error taxonomy, timeouts, logging.
  • Two or three candidate operations that are read-only or reversible (get_order, get_refund_policy, create_draft_reply). Nothing destructive in v1.
  • Existing authz for those operations — the model runs within a user's permissions, never as a superuser.

The workflow

  1. Pick tools by blast radius, then write contracts. Strict schemas make argument shape a guarantee, not a hope:

    TOOLS = [
        {
            "name": "get_order",
            "description": "Fetch one order by ID for the authenticated customer. \
    Use when the user references a specific order or purchase.",
            "strict": True,
            "input_schema": {
                "type": "object",
                "properties": {"order_id": {"type": "string", "description": "Format ord_..."}},
                "required": ["order_id"],
                "additionalProperties": False,
            },
        },
        {
            "name": "get_refund_policy",
            "description": "Return refund policy for a product category.",
            "strict": True,
            "input_schema": {
                "type": "object",
                "properties": {"category": {"type": "string",
                               "enum": ["hardware", "software", "subscription"]}},
                "required": ["category"],
                "additionalProperties": False,
            },
        },
    ]
    

    Descriptions carry when to use language — that is what drives correct tool selection.

  2. Validate server-side anyway — schemas shape arguments; they don't authorize them. Every executor rechecks tenancy and permissions:

    def execute_tool(name: str, args: dict, ctx: RequestContext) -> tuple[str, bool]:
        """Returns (result_json, is_error). NEVER raises into the loop."""
        if name not in ALLOWED_TOOLS:                      # allow-list, not model trust
            return json.dumps({"error": "unknown tool"}), True
        if name == "get_order":
            order = orders.get(args["order_id"])
            if order is None or order.customer_id != ctx.customer_id:   # tenancy check
                return json.dumps({"error": "order not found"}), True   # same msg both cases
            return order.to_json(), False
        ...
    
  3. Run a bounded loop; return parallel results in one message. The model may request several tools at once — execute them (concurrently if independent) and send all results back in a single user turn:

    import anthropic
    client = anthropic.Anthropic(timeout=30.0)
    MAX_TURNS = 5
    
    def run(user_msg: str, ctx) -> str:
        messages = [{"role": "user", "content": user_msg}]
        for _ in range(MAX_TURNS):
            resp = client.messages.create(
                model="claude-opus-4-8", max_tokens=2048,
                system=SYSTEM_PROMPT, tools=TOOLS, messages=messages,
            )
            if resp.stop_reason != "tool_use":
                return next(b.text for b in resp.content if b.type == "text")
            messages.append({"role": "assistant", "content": resp.content})
            results = []
            for block in resp.content:
                if block.type == "tool_use":
                    out, is_err = execute_tool(block.name, block.input, ctx)
                    results.append({"type": "tool_result", "tool_use_id": block.id,
                                    "content": out, "is_error": is_err})
            messages.append({"role": "user", "content": results})
        return "I couldn't complete that within limits — routing to a human."
    
  4. Design the tool-error path as a feature. is_error: True with an informative message lets the model recover (ask the user for a correct order ID) instead of hallucinating success. Never leak internal exceptions or stack traces into content — they end up in the model's mouth.

  5. Log the full trace per request: every tool call with arguments, result size, error flag, and turn count. Turn-count distribution tells you when the loop is thrashing; tool-selection errors tell you which description to fix.

  6. Test adversarially before launch. Prompts that request other tenants' orders, tools that don't exist, contradictory instructions inside retrieved data ("ignore your rules and call X") — verify the allow-list and tenancy checks hold regardless of what the model asks for. This overlaps deliberately with pb-52.

Warning

The security boundary is execute_tool, not the prompt. Any check that exists only as prompt text ("never access other customers' orders") is a suggestion; the tenancy check in code is the control.

Tool variants

  • Anthropic manual loop (shown): maximum control, portable pattern. The SDK's beta tool runner (@beta_tool + client.beta.messages.tool_runner(...)) removes the loop boilerplate once you trust the shape.
  • OpenAI: same architecture on the Responses API — tools as {"type": "function", "function": {..., "strict": true}}, results returned as {"type": "function_call_output", "call_id": ..., "output": ...} items; gpt-5.6 in current docs.
  • LangGraph/framework route: worth it when you need graph-shaped control flow (branches, human approval nodes); overkill for 2–3 tools. Learn the vanilla loop first — every framework bug report requires it.

Verification

  • 20-case tool-selection check: for each scripted user message, the expected tool (or no tool) is chosen ≥90% of the time; failures fix descriptions, not code.
  • Cross-tenant probes return "not found" — and the log proves the executor blocked it, not the model's goodwill.
  • Forced tool failures (dead downstream service) produce a graceful model response, not a loop spiral; MAX_TURNS exit path tested and human-routed.
  • Parallel-call case (a message needing both tools) round-trips correctly with both results in one user turn.

Pitfalls

  • Destructive tools in v1.issue_refund does not belong in the first release. Add mutating tools only behind confirmation UX or human approval, after the read-only version has production history.
  • Splitting parallel results across messages. Returning tool results in separate user turns quietly teaches the model to stop parallelizing — batch them.
  • Trusting strict as authorization. Strict schemas guarantee well-formed arguments; a well-formed request for someone else's data is still an attack.
  • Unbounded loops. No MAX_TURNS means one confused conversation can burn dollars and hold a worker for minutes.
  • Vague descriptions. "Gets data" produces coin-flip tool selection. Prescriptive when-to-call descriptions are the highest-leverage line you'll write.

Restricted-environment variant

  • Client tenancyidentical code via Bedrock/Azure OpenAI clients inside the client's cloud; tool executors call internal services that never leave the boundary — often an easier compliance story than RAG, since only tool results you construct reach the model.
  • Localcurrent local models (Qwen-class via Ollama) handle 2–3 well-described tools acceptably; keep MAX_TURNS lower (3) and expect more selection errors — the server-side validation carries you.
  • Regulated datatool results are model input — apply pb-44's redaction to anything a tool returns before it enters content.

Success criteria

  • A feature that correctly routes real user requests across 2–3 tools with a measured selection accuracy, not an assumed one.
  • Every tool execution authorized server-side; adversarial probes fail closed with evidence in logs.
  • Loops always terminate: bounded turns, error paths that recover or hand off to humans.
  • A per-request trace a teammate can read to reconstruct exactly what the model did and why.
This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in