Skip to main content
All posts

AI Agents

13 June 2026

AI Agents: Why Simple Chains Beat Complex Orchestration

I’ve built nine AI features into CitizenApp, and I keep seeing the same pattern: developers get seduced by “agentic” architectures when a straightforward chain of function calls would work better.

Let me be direct: most AI agent frameworks are over-engineered. They look impressive in demos, but they introduce latency, unpredictability, and debugging nightmares in production. I prefer explicit chains with clear control flow because I can reason about them at 3am when something breaks.

What People Mean by “AI Agents”

When folks say “agents,” they usually mean one of two things:

  1. Autonomous decision-making loops – An LLM decides what tool to call, calls it, sees the result, decides the next step
  2. Function calling with retry logic – Structured tool use with error handling and fallback strategies

The first one sounds magical. It’s also fragile.

Here’s why: every decision loop adds latency and a chance for the model to hallucinate. If you’re building a user-facing feature, you can’t afford to have your AI agent decide to call the wrong endpoint three times before giving up.

The CitizenApp Approach: Explicit Chains

In CitizenApp, I use what I call “orchestrated chains” – the developer defines the flow, the AI fills in the details.

Here’s a real example from our document classification feature:

async function classifyAndExtractDocument(
  documentText: string,
  userId: string
): Promise<ClassificationResult> {
  // Step 1: Extract structured data
  const extracted = await extractWithClaude(documentText, {
    fields: ['documentType', 'issueDate', 'amount', 'parties'],
  });

  // Step 2: Validate against known schema
  const validated = validateSchema(extracted, documentType);

  // Step 3: If validation fails, ask Claude to correct
  if (!validated.success) {
    const corrected = await extractWithClaude(documentText, {
      fields: ['documentType', 'issueDate', 'amount', 'parties'],
      instructions: `Previous attempt failed validation: ${validated.errors.join(', ')}. Please re-extract with these constraints in mind.`,
    });
    return corrected;
  }

  // Step 4: Enrich with business logic
  const enriched = await enrichDocumentData(validated.data, userId);

  return enriched;
}

Notice: no loops, no tool-calling framework, no “let the AI figure it out.” The developer controls the flow. Claude does what it’s good at—understanding text and extracting meaning.

When (Rarely) You Need Real Agents

I use actual agentic loops in exactly one place in CitizenApp: our research assistant. Here’s why it works there:

async def research_assistant(query: str, user_id: str, max_iterations: int = 5):
    """
    Actual agent loop. Used sparingly. Only when the problem
    is exploratory and latency isn't critical.
    """
    conversation_history = []
    
    for iteration in range(max_iterations):
        # Get model's decision
        response = await claude.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=1024,
            system=RESEARCH_SYSTEM_PROMPT,
            tools=RESEARCH_TOOLS,
            messages=conversation_history
        )
        
        # Check if done
        if response.stop_reason == "end_turn":
            return extract_final_answer(response)
        
        # Process tool calls
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                result = await execute_research_tool(block.name, block.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result
                })
        
        # Add to history and continue
        conversation_history.append({"role": "assistant", "content": response.content})
        conversation_history.append({"role": "user", "content": tool_results})
    
    return {"error": "Max iterations reached"}

This works because the research assistant runs async in the background. The user gets told “researching…” and waits. Not ideal for API responses.

The Performance Cost

Here’s what burned me: I started with LangChain’s agent executor for a simpler use case. On paper, it looked elegant. In practice:

I switched back to explicit chains. Same capabilities, 70% less latency, fraction of the cost.

My Rules of Thumb

Use explicit chains when:

Use agent loops when:

The Right Tool

For most SaaS features, Claude + structured outputs + explicit orchestration beats “agentic” frameworks every time.

const result = await extractStructuredData(
  input,
  zodSchema(MyOutputShape)
);

This is less “AI” (less autonomous), but more reliable. And in production, reliability beats magic.

Gotcha: Tool Use Isn’t the Same as Agents

I lumped these together early on. They’re not the same thing.

Tool use = the model can call functions. Developers still control the loop.

Agents = the model decides what to do, including whether to use tools and when to stop.

Tool use is great. Tool use + explicit orchestration is my preferred pattern. Agents make me nervous. Your mileage may vary depending on your risk tolerance and latency requirements.

The unsexy truth: most winning AI features aren’t “agents” at all. They’re Claude doing one thing very well, wrapped in clear application logic.

Comments

All comments are moderated before appearing.

Loading comments…

Leave a comment

0/2000

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch