Most AI agent demos I see work by having the model reason its way through a problem, decide what to do next, and take action. This works beautifully in controlled demos and fails reliably in production environments with real business data, real edge cases, and real users who do not behave like the test prompts.
The production agents I have deployed that actually stay deployed share a different architecture. The model does not reason freely through the workflow. It routes to tools, and the tools do the deterministic work. Reasoning is reserved for the single task that LLMs are genuinely good at: understanding natural language intent and mapping it to a structured action.
Here is why this matters and what the architecture looks like in practice.
Two Architectures, One Clear Winner in Production
Reasoning-heavy agent:
User input --> LLM reasons about steps --> LLM decides action --> LLM executes
--> LLM evaluates result --> LLM decides next step --> repeat
Tool-use agent:
User input --> LLM classifies intent (constrained JSON output)
--> Route to deterministic tool
--> Tool executes
--> Validate output
--> LLM formats response
The reasoning-heavy agent has a compounding failure problem. Each reasoning step introduces some probability of error. In a five-step reasoning chain with 90% accuracy per step, end-to-end accuracy is 59%. In an SMB workflow where your client runs a clinic or a logistics company, 59% reliability is not a product; it is a liability.
The tool-use agent concentrates the LLM's role: classify what the user wants, map it to a tool, validate the output. The deterministic tools handle the execution. You get LLM flexibility at the front and back, with reliable code in the middle.
What Tool-Use Actually Means in Production
A tool is a function the LLM can call. OpenAI calls them function calls. Anthropic calls them tool use. The pattern is the same: you define a function signature with a JSON schema, the LLM decides when to call it and with what arguments, and your code executes it.
The key constraint: every tool should do exactly one thing and do it deterministically. No tool should contain business logic that varies based on context or has side effects that are difficult to reverse.
# Good tool: single responsibility, deterministic, safe to retry
def get_patient_appointments(
patient_id: str,
date_start: str,
date_end: str
) -> list[dict]:
"""Retrieve appointments for a patient within a date range."""
return db.query(
"SELECT * FROM appointments "
"WHERE patient_id = ? AND date BETWEEN ? AND ?",
[patient_id, date_start, date_end]
)
# Bad tool: multiple responsibilities, hard to test, hard to retry safely
def handle_appointment_request(patient_message: str) -> str:
"""Parse message, look up patient, check availability, book, send confirmation."""
# 80 lines of mixed logic -- every line is a potential failure point
...
The bad tool is what you get when you ask a developer to "just build a tool that handles appointments." The good tool is what you get when you design each tool as an atomic database operation or API call, then let the LLM orchestrate them.
The Architecture I Deploy
Here is the pattern I use for SMB agents across clinic scheduling, property inquiry handling, and invoice processing:
+-----------------------------+
| User Input |
+-------------+---------------+
|
+-------------v---------------+
| Intent Classifier |
| (LLM, constrained schema) |
+-------------+---------------+
|
+-------------v---------------+
| Tool Router |
| (deterministic dispatch) |
+------+-------+-------+------+
| | |
+-----------v-+ +---v---+ +-v-----------+
| Read Tools | | Write | | Integration |
| (DB reads) | | Tools | | Tools (API) |
+------+------+ +---+---+ +------+------+
| | |
+-v------------v------------v-+
| Output Validator |
| (schema + business rules) |
+-------------+---------------+
|
+-------------v---------------+
| Response Formatter |
| (LLM, constrained output) |
+-----------------------------+
The intent classifier uses a constrained schema. The LLM is not writing a free-text plan; it is returning a JSON object with an intent enum and extracted parameters. If it returns anything that does not match the schema, the request fails fast and routes to a fallback.
from pydantic import BaseModel
from enum import Enum
class Intent(str, Enum):
BOOK_APPOINTMENT = "book_appointment"
CANCEL_APPOINTMENT = "cancel_appointment"
GET_AVAILABILITY = "get_availability"
GENERAL_INQUIRY = "general_inquiry"
class IntentResult(BaseModel):
intent: Intent
patient_id: str | None = None
requested_date: str | None = None
appointment_type: str | None = None
confidence: float
# The LLM returns this structured object, not a free-text reasoning chain
Why This Works for SMBs Specifically
SMB workflows have properties that make tool-use especially valuable.
The process is known. A clinic's appointment booking process has maybe six steps. A property inquiry flow has maybe eight. These are not open-ended research tasks requiring exploration. They are deterministic workflows with well-defined happy paths and a finite set of exception types. The LLM's job is to understand which workflow the user is requesting and extract the parameters. Execution belongs in code.
The staff can debug code but not LLM reasoning. When a reasoning agent fails, the failure is a narrative: "the model decided to do X because it interpreted Y as Z." When a tool-use agent fails, the failure is a log line: tool get_availability returned null for patient_id=1234. The second failure is fixable in five minutes. The first requires prompt archaeology.
Cost matters at SMB scale. A reasoning agent burns tokens on every step of its reasoning chain. A tool-use agent burns tokens on intent classification and response formatting. For a clinic handling 200 patient inquiries per day, the difference between a 3,000-token reasoning chain and a 400-token classification call is roughly $0.25 versus $0.03 per interaction. At volume across a month, that gap is real money on a client who is already skeptical about AI costs.
The Validation Step That Changes Everything
Between tool execution and the response formatter, I add a validation step that most agent implementations skip. It does two things: checks that the tool output matches the expected schema, and applies business rules that would be expensive or unreliable to evaluate inside the LLM.
For the Karachi cardiology clinic agent, validation rules included: appointment slots must be at least 30 minutes in the future, a patient cannot have two appointments of the same type within 48 hours, and the requested doctor must be available on the requested date. None of these rules belong in the LLM prompt. They belong in code: tested, versioned, and auditable.
from datetime import datetime, timedelta
def validate_booking_request(
patient_id: str,
doctor_id: str,
appointment_type: str,
requested_slot: datetime
) -> tuple[bool, str | None]:
now = datetime.utcnow()
# Rule 1: slot must be in the future
if requested_slot < now + timedelta(minutes=30):
return False, "slot_too_soon"
# Rule 2: no duplicate appointment type within 48 hours
recent = db.query(
"SELECT id FROM appointments "
"WHERE patient_id=? AND type=? AND date > ?",
[patient_id, appointment_type, now - timedelta(hours=48)]
)
if recent:
return False, "duplicate_appointment_type"
# Rule 3: doctor availability
if not is_doctor_available(doctor_id, requested_slot):
return False, "doctor_unavailable"
return True, None
What I Got Wrong
I built my first production agent as a reasoning-heavy chain. It worked in testing. In production, a patient sent "can i come tuesday" and the agent booked an appointment for the wrong Tuesday (four weeks out, not four days out) because the date resolution step assumed the current date was different from what it was. The reasoning chain had no place to inject a verified current timestamp.
The tool-use agent solves this with a system context object that every tool receives, including the verified current timestamp from the server clock. This is obvious in retrospect and not obvious at all when you are building your first agent from a demo you saw on YouTube.
I also underestimated the importance of fallback handling. My early agents had no graceful path for "I do not understand this request." A reasoning agent can waffle its way to a plausible-sounding but incorrect response. A tool-use agent that cannot match the input to a known intent should say so explicitly and offer the user a way to reach a human. That escalation path is a feature, not a failure.
Production Reality
The agents I have deployed that are still running after twelve months without major incidents are all tool-use agents with constrained intent classification, atomic deterministic tools, and a validation layer between execution and response.
The ones that failed were reasoning agents where I trusted the LLM to navigate edge cases. It does not. Not reliably. Not with real user inputs at real business stakes.
The boring architecture is the right architecture. Constrain the LLM to what it is good at. Code everything else.