{"slug": "what-production-ai-agents-actually-require", "title": "What production AI agents actually require", "summary": "Production AI agents require robust state management, idempotency, audit trails, and rollback capabilities, not just fluent LLM calls. A senior engineer argues that most agent demos fail in real workflows because they lack these unglamorous but critical layers, such as persisted state and idempotency keys for external effects.", "body_md": "# What production AI agents actually require\n\n*Building agentic AI? I co-run a 6-week cohort where you ship a production-ready agent, not another API wrapper.*\n\nMost \"AI agents\" shipping right now are demos wearing production paint. They answer questions fluently and break the moment they touch a workflow with money, state, or consequences.\n\n## The agent illusion\n\nMulti-agent frameworks benchmark beautifully. Five specialist LLMs cooperate, the demo plays cleanly, the README has a diagram with arrows. Then someone wires the thing to a real billing system and it issues three refunds for the same chargeback because a tool call retried on a flaky network.\n\nThat gap is the actual job most agent tutorials skip.\n\nWhen I review AI code, the same pattern keeps appearing. The LLM call is fine. The agent loop is fine. What is missing is the layer underneath: state, idempotency, audit, and a tool surface the agent cannot use to hurt you.\n\n## The unsexy layer\n\nSystems answer four questions before the agent does anything:\n\n**What did the agent already do?** Persisted state, not \"look at the conversation history.\"**What happens if this action runs twice?** Idempotency keys on every external effect.**Who approved this?** An audit log a human can read during a postmortem.**Can I roll this back?** A clear inverse for every irreversible operation, or a freeze before execution.\n\nNone of this is glamorous. It is also what separates a system that is a toy demo from one that can run mostly unsupervised in production.\n\nThe shape of that contract in code:\n\n```\nclass ExpenseAction(BaseModel):\n    idempotency_key: str\n    requested_by: str\n    requested_at: datetime\n    approval_required: bool = True\n    dry_run: bool = True\n    payload: ExpensePayload\n\ndef submit(action: ExpenseAction, repo: ExpenseRepo) -> Result:\n    if repo.find_by_key(action.idempotency_key):\n        return Result.duplicate()\n    if action.dry_run:\n        return Result.preview(action.plan())\n    if action.approval_required and not action.is_approved():\n        return Result.pending_approval()\n    repo.persist(action)\n    return Result.ok(action.execute())\n```\n\nThe agent does not call the side effect. It builds a typed plan. A function decides whether to run it.\n\n## State that survives retries\n\nAgents need state management that works across restarts and network failures. The Telegram expense bot we build in our Agentic AI cohort program, uses `context.user_data`\n\nto track multi-step flows:\n\n``` python\nasync def handle_expense_text(self, update, context):\n    text = update.message.text\n    result = self._preprocessor.preprocess(text)\n\n    if not result.is_valid:\n        await update.message.reply_text(f\"Invalid: {result.error}\")\n        return ConversationHandler.END\n\n    response = self._build_service().classify(result.text).response\n\n    # Store state for the callback handler\n    context.user_data[\"expense_description\"] = result.text\n    context.user_data[\"classification_response\"] = response\n\n    keyboard = build_category_confirmation_keyboard(\n        suggested_category=response.category,\n        all_categories=[c.value for c in ExpenseCategory],\n    )\n\n    await update.message.reply_text(\n        f\"I categorized this as {response.category} ({response.total_amount} {response.currency}). Confirm or pick another category:\",\n        reply_markup=keyboard,\n    )\n\n    return ConversationState.WAITING_FOR_CATEGORY\n\nasync def handle_category_selection(self, update, context):\n    query = update.callback_query\n    await query.answer()\n\n    # Retrieve state from previous handler\n    description = context.user_data.get(\"expense_description\")\n    response = context.user_data.get(\"classification_response\")\n\n    if description is None or response is None:\n        await query.edit_message_text(\"Session expired. Send expense again.\")\n        return ConversationHandler.END\n\n    _, category = query.data.split(\":\", 1)\n\n    self._build_service().persist_with_category(\n        expense_description=description,\n        category_name=category,\n        response=response,\n        telegram_user_id=update.effective_user.id,\n    )\n\n    await query.edit_message_text(f\"Saved as {category}!\")\n    return ConversationHandler.END\n```\n\nThe `.get()`\n\nwith defensive error handling is what saves you when the bot restarts mid-conversation. No silent corruption, no half-written database rows. The user just has to resend their expense description and pick the category again. This is the work of production agents.\n\n## Tools the agent cannot trust\n\nLLMs are undeterministic and hallucinate. Design your tool surface for mistrust:\n\n**Narrow scopes.**`read_expense`\n\nand`flag_expense`\n\nare two tools, not one tool with a mode flag the LLM can flip.**Dry-run by default.** Every write tool returns a plan first. The agent opts in to execute. You get human-in-the-loop (HITL) for free.**Schema-validated inputs.**[Pydantic at the tool boundary](/blog/ai-agent-architecture-python/)so a malformed argument cannot reach your database.** Explicit confirmation for anything destructive.**The agent proposes, a human taps approve.\n\nThe agent is not the brain of your application. It is a planner that we acknowledge is fallible. The real logic lives in the tools, and the agent's job is to call them with valid inputs and ask for help when it is unsure.\n\n## Input validation before the LLM sees anything\n\nValidate at system boundaries before user input reaches your tools. This prevents XSS, length attacks, and malformed data from consuming tokens:\n\n``` python\nfrom dataclasses import dataclass, field\nimport re\n\nXSS_PATTERNS = (\"<script\", \"javascript:\", \"onerror=\", \"onload=\")\nCURRENCY_SYMBOLS = {\"$\": \"USD\", \"€\": \"EUR\", \"£\": \"GBP\", \"¥\": \"JPY\"}\nAMOUNT_PATTERN = re.compile(r\"\\d+([.,]\\d+)?\")\n\n@dataclass\nclass PreprocessingResult:\n    text: str\n    is_valid: bool\n    warnings: list[str] = field(default_factory=list)\n    error: str | None = None\n\nclass InputPreprocessor:\n    def preprocess(self, text: str) -> PreprocessingResult:\n        text = text.strip()\n\n        if len(text) < 3:\n            return PreprocessingResult(text, False, error=\"Input too short\")\n        if len(text) > 500:\n            return PreprocessingResult(text, False, error=\"Input too long\")\n\n        if any(pattern in text.lower() for pattern in XSS_PATTERNS):\n            return PreprocessingResult(text, False, error=\"Invalid characters\")\n\n        for symbol, code in CURRENCY_SYMBOLS.items():\n            text = text.replace(symbol, code)\n\n        warnings = []\n        if not AMOUNT_PATTERN.search(text):\n            warnings.append(\"No amount detected\")\n\n        return PreprocessingResult(text, True, warnings)\n```\n\nThis runs before the LLM call, returning error messages without burning tokens or risking injection.\n\n## Human-in-the-loop as a design pattern\n\nProduction agents are not fully autonomous. They classify, extract, or suggest, then wait for a human to confirm. Confidence scores guide when to ask:\n\n``` python\nfrom dataclasses import dataclass\n\n@dataclass(frozen=True)\nclass ClassificationResult:\n    response: ExpenseCategorizationResponse\n    persisted: bool\n\ndef process_with_hitl(result: ClassificationResult, threshold: float = 0.8) -> str:\n    if result.response.confidence >= threshold:\n        return result.response.category\n\n    print(\n        f\"Low confidence ({result.response.confidence:.0%}): '{result.response.category}' — {result.response.reason}\"\n    )\n    user_input = input(\n        f\"Accept '{result.response.category}'? (Enter to confirm, or type a category): \"\n    ).strip()\n\n    if not user_input:\n        return result.response.category\n    return user_input\n```\n\nIn the Telegram bot, this becomes an inline keyboard. The bot states its category guess and asks the human to confirm or pick a different one, with the AI suggestion highlighted.\n\nThe pattern: AI proposes, human disposes. This surfaces in the service layer we built in prior weeks:\n\n```\n@dataclass\nclass ClassificationService:\n    assistant: Assistant\n    expense_repo: ExpenseRepository\n\n    def classify(self, description: str) -> ClassificationResult:\n        messages = self._build_messages(description)\n        response = self.assistant.completion(messages)\n        return ClassificationResult(response=response, persisted=False)\n\n    def persist_with_category(\n        self,\n        expense_description: str,\n        category_name: str,\n        response: ExpenseCategorizationResponse,\n        telegram_user_id: int | None = None,\n    ) -> None:\n        \"\"\"Store the user's chosen category, not the AI guess.\"\"\"\n        expense = Expense(\n            amount=response.total_amount,\n            currency=response.currency,\n            category=ExpenseCategory(category_name),\n            description=expense_description,\n            telegram_user_id=telegram_user_id,\n        )\n        self.expense_repo.add(expense)\n```\n\nThe `persist_with_category`\n\nmethod accepts the human's decision. The database stores what the user confirmed, not what the model guessed. As the `ExpenseCategorizationResponse`\n\ncaptures the AI's original category and confidence, we can analyze overrides later to identify model weaknesses.\n\n## Dependency injection for testable agents\n\nThe service layer pattern separates business logic from LLM provider details. Inject dependencies rather than hardcoding them:\n\n``` python\nfrom unittest.mock import create_autospec\nfrom decimal import Decimal\n\ndef test_classify_calls_assistant():\n    # No real OpenAI call, no .env file, no network\n    mock_assistant = create_autospec(Assistant)\n    mock_assistant.completion.return_value = ExpenseCategorizationResponse(\n        category=\"Food\",\n        total_amount=Decimal(\"5.50\"),\n        currency=Currency.USD,\n        confidence=0.95,\n        cost=Decimal(\"0.001\"),\n    )\n\n    mock_repo = create_autospec(ExpenseRepository)\n    service = ClassificationService(assistant=mock_assistant, expense_repo=mock_repo)\n    result = service.classify(\"Coffee at Starbucks $5.50\")\n\n    mock_assistant.completion.assert_called_once()\n    assert result.response.category == \"Food\"\n    assert result.persisted is False\n```\n\nBecause the service receives its dependencies rather than creating them, you can test classification logic without burning API credits or waiting on network calls. This is a key strategy to test the interface at the service layer, not the LLM provider.\n\nThe same service powers the CLI, Telegram bot, and REST API. Change providers (OpenAI to Anthropic) or add caching by swapping the `Assistant`\n\nimplementation. Business logic stays untouched.\n\n## Speed vs safety\n\nThe tradeoff is iteration speed vs execution safety.\n\nPut the LLM behind a typed [service boundary](/blog/repository-pattern-swappable-data-sources/) and you can swap models without touching business logic. Store actions as events instead of overwriting state, and your audit log writes itself. I wrote about [why event sourcing pays off](/blog/event-sourcing-python-store-events-not-state/).\n\n## Agentic loops with typed tool results\n\nThe tool-use loop needs to handle partial results, retries, and tool failures. Here is the pattern from the warm up exercises you can do on [our Agentic Cohort page](https://pythonagenticai.com):\n\n``` python\nfrom typing import cast\nimport anthropic\nfrom anthropic.types import (\n    MessageParam,\n    TextBlock,\n    ToolUseBlock,\n    ToolResultBlockParam,\n)\n\n# TOOLS defined with JSON schema for get_exchange_rate(from_currency, to_currency)\n\ndef answer_with_tools(question: str, client: anthropic.Anthropic) -> str:\n    messages: list[MessageParam] = [{\"role\": \"user\", \"content\": question}]\n\n    while True:\n        response = client.messages.create(\n            model=\"claude-sonnet-4-6\",\n            max_tokens=512,\n            tools=TOOLS,\n            messages=messages,\n        )\n\n        if response.stop_reason == \"end_turn\":\n            return cast(TextBlock, response.content[0]).text\n\n        if response.stop_reason != \"tool_use\":\n            # anything other than tool_use here means no tool calls to process — looping would spin forever\n            raise RuntimeError(f\"Unexpected stop reason: {response.stop_reason}\")\n\n        tool_uses = [\n            cast(ToolUseBlock, b) for b in response.content if b.type == \"tool_use\"\n        ]\n        tool_results: list[ToolResultBlockParam] = [\n            {\n                \"type\": \"tool_result\",\n                \"tool_use_id\": b.id,\n                \"content\": str(get_exchange_rate(**cast(dict[str, str], b.input))),\n            }\n            for b in tool_uses\n        ]\n\n        messages.append({\"role\": \"assistant\", \"content\": response.content})\n        messages.append({\"role\": \"user\", \"content\": tool_results})\n```\n\nThe loop continues until `stop_reason == \"end_turn\"`\n\n. Tool results are typed, preventing schema drift between the tool definition and implementation.\n\nIn production, wrap `get_exchange_rate()`\n\nin a try/except and return error results to the LLM when tools fail. The agent can retry, pick a different tool, or surface the error to the user.\n\nThe fix is separation of concerns, typed interfaces, and a well-defined contract between the agent and its tools.\n\n## Keep reading\n\nMost AI tutorials end at \"call the API.\" This cohort ends with a deployed agent: function calling, structured outputs, three interfaces, Docker, 95%+ test coverage. Six weeks of real engineering, not notebooks. [Join the next Agentic AI cohort →](https://pythonagenticai.com)", "url": "https://wpnews.pro/news/what-production-ai-agents-actually-require", "canonical_source": "https://belderbos.dev/blog/production-ai-agents-real-workflows/", "published_at": "2026-05-22 00:00:00+00:00", "updated_at": "2026-06-17 10:02:03.053351+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-infrastructure", "large-language-models"], "entities": ["Telegram"], "alternates": {"html": "https://wpnews.pro/news/what-production-ai-agents-actually-require", "markdown": "https://wpnews.pro/news/what-production-ai-agents-actually-require.md", "text": "https://wpnews.pro/news/what-production-ai-agents-actually-require.txt", "jsonld": "https://wpnews.pro/news/what-production-ai-agents-actually-require.jsonld"}}