Most people think AI is just a chatbot - you type a question, it gives you an answer. But the real power of AI is not in answering questions. It is in doing things. Instead of asking an AI to "write an email," what if you could give it a goal like "find all customers who haven't paid, send them a reminder, and update the spreadsheet"- and it just does it?
That is what agentic orchestrators do. This post explains what they are, how they work, and why they matter - in plain language, with no assumed knowledge. Every concept is followed by working code you can run yourself.
What you will learn: What an orchestrator is · How it differs from a chatbot · The 5-step execution loop · How to build one from scratch in Python · Error handling, cost, and safety · A complete working example
1. Chatbots vs. Orchestrators: The Core Difference
Let us start with something familiar. Open ChatGPT and type: "Write a summary of the latest AI research." The AI will write a summary based on what it already knows. It cannot search the internet (unless you give it that tool). It cannot access your files. It just predicts the next word based on its training data.
That is a chatbot. One prompt in, one response out. No tools. No memory between sessions. No ability to check if its answer is correct.
Diagram 1: How a chatbot works
Now imagine something different. You give the AI a goal, not a prompt. You also give it tools: a search engine, a database, an email API. The AI does not just respond - it acts.
It makes a plan. It executes each step. If a step fails, it fixes itself and tries again. When the goal is complete, it tells you. That is an orchestrator.
Diagram 2: How an orchestrator works
The chatbot gives you information. The orchestrator does work. That is the fundamental shift this entire post is about.
2. Building an Orchestrator: First Principles
Let us build a tiny orchestrator from scratch. We will start with nothing but Python and build up, step by step. You can copy this code and run it yourself.
Step 1: A Tool
An orchestrator needs tools - things it can use to interact with the world. Let us define what a tool looks like in code:
from dataclasses import dataclass
from typing import Callable
@dataclass
class Tool:
name: str # e.g. "search"
description: str # e.g. "Search the web for information"
function: Callable # The actual code that runsA tool has three parts: a name so the AI can refer to it, a description so the AI knows when to use it, and a function that does the actual work. Here is a concrete example:
def search_web(query: str) -> str:
"""Pretend to search the web."""
return f"Results for: {query}"
search_tool = Tool(
name="search",
description="Search the web for information",
function=search_web
)A tool is just a named function with a description
Step 2: A Goal
The user gives the orchestrator a goal. For now, it is just a string:
goal = "Find the latest news about AI in healthcare and save it to a file."Step 3: The Plan
The orchestrator needs to turn the goal into a list of steps. In a real system, this is done by an LLM. For our example, we will hard-code the planning to keep it simple:
@dataclass
class Step:
tool_name: str
input: str
def make_plan(goal: str) -> list[Step]:
"""Break a goal into steps."""
# In a real system, an LLM does this.
# Here we hard-code the plan for demonstration.
return [
Step(tool_name="search", input="AI healthcare latest news"),
]Step 4: Execute Each Step
The orchestrator loops through the plan, finds the right tool for each step, and runs it. This is the core loop:
def execute_step(step: Step, tools: list[Tool]) -> str:
"""Find the right tool and run it."""
for tool in tools:
if tool.name == step.tool_name:
return tool.function(step.input)
return f"Error: no tool named {step.tool_name}"
# The execution loop
results = []
for step in make_plan(goal):
result = execute_step(step, [search_tool])
results.append(result)
print(f"Step done: {step.tool_name}")This is the heart of every orchestrator. A simple loop: for each step, find the tool, run it, save the result. Everything else - planning, error handling, retrying - is built on top of this loop.
3. Adding Error Handling: The Self-Correction Loop
The most important feature of an orchestrator is that it does not crash when something goes wrong. Let us add error handling to our loop:
@dataclass
class StepResult:
success: bool
output: str
error: str = ""
def execute_step_safe(step: Step, tools: list[Tool]) -> StepResult:
"""Run a step and return a result, even if it fails."""
try:
result = execute_step(step, tools)
return StepResult(success=True, output=result)
except Exception as e:
return StepResult(success=False, output="", error=str(e))
# The self-correcting loop
max_retries = 3
for step in make_plan(goal):
for attempt in range(max_retries):
result = execute_step_safe(step, [search_tool])
if result.success:
print(f"✓ Step succeeded: {step.tool_name}")
break
else:
print(f"✗ Attempt {attempt + 1} failed: {result.error}")
if attempt < max_retries - 1:
print(" Retrying...")
else:
print(f" All attempts failed for: {step.tool_name}")The self-correction loop visualised
This pattern - try, check, retry if failed, move on if succeeded - is what separates a real orchestrator from a prototype. In production systems, you also add:
- Exponential backoff: Wait 1 second, then 2, then 4 before retrying
- Different fallback tools: If the search API fails, try a different one
- Human escalation: If all retries fail, tell a human
4. The Complete Orchestrator
Here is everything put together - a complete, runnable orchestrator in under 50 lines:
from dataclasses import dataclass
from typing import Callable
import time
# ── 1. Define a tool ──
@dataclass
class Tool:
name: str
desc: str
fn: Callable
def search(q: str) -> str:
return f"Top result for: {q}"
tools = [Tool("search", "Search the web", search)]
# ── 2. Define a step ──
@dataclass
class Step:
tool: str
input: str
# ── 3. The orchestrator ──
def run(goal: str, tools: list[Tool], max_retries=3):
# Plan (hardcoded for demo - LLM does this in production)
steps = [Step("search", goal)]
for step in steps:
for attempt in range(max_retries):
try:
for t in tools:
if t.name == step.tool:
result = t.fn(step.input)
print(f"✓ {step.tool}: {result}")
break
break # success → move to next step
except Exception as e:
print(f"✗ Attempt {attempt+1}: {e}")
time.sleep(1) # wait before retry
else:
print(f" All retries failed for: {step.tool}")
# ── 4. Run it ──
run("Find AI news", tools)
# Output:
# ✓ search: Top result for: Find AI newsThis is a real, working orchestrator. It is tiny - but it contains the same fundamental pattern used by production systems at companies like a global ratings agency anda global investment bank. Everything else - LLM-based planning, parallel execution, state persistence, observability - is built on top of this basic loop.
5. What Production Orchestrators Add
The example above is minimal. Production orchestrators add several layers on top:
LLM-Based Planning
Instead of hard-coded steps, a real orchestrator uses an LLM to generate the plan dynamically. The LLM reads the goal, looks at the available tools, and decides what steps to take:
def plan_with_llm(goal: str, tools: list[Tool]) -> list[Step]:
prompt = f"Goal: {goal}\n"
prompt += f"Available tools: {[t.name for t in tools]}\n"
prompt += "What steps should I take?"
# In reality: call OpenAI / Anthropic API
# The LLM returns a structured plan
return parse_llm_response(llm.generate(prompt))Parallel Execution
Steps that do not depend on each other can run at the same time, reducing total execution time:
from concurrent.futures import ThreadPoolExecutor
def run_parallel(steps: list[Step], tools: list[Tool]):
with ThreadPoolExecutor(max_workers=5) as pool:
results = pool.map(lambda s: execute(s, tools), steps)
return list(results)State Management
Production orchestrators save their state between steps. If the server crashes and restarts, the orchestrator picks up where it left off - it does not start over:
@dataclass
class OrchestratorState:
goal: str
completed_steps: list[Step]
current_step: int
results: dict
def save_state(state: OrchestratorState):
with open("state.json", "w") as f:
json.dump(state.__dict__, f)
def load_state() -> OrchestratorState:
with open("state.json") as f:
data = json.load(f)
return OrchestratorState(**data)Production orchestrator - the full stack
6. The Hard Parts: What Makes This Difficult
Building a simple orchestrator is easy (you just did it). Building one that works reliably in production is hard. Here are the three biggest challenges:
Challenge 1: Reliability
Every external API can fail. Networks timeout. Databases go down. Rate limits get hit. Your orchestrator must handle all of these gracefully:
def execute_with_retry(step, tools, max_retries=3):
for attempt in range(max_retries):
try:
return execute_step(step, tools)
except TimeoutError:
wait = 2 ** attempt # exponential backoff
time.sleep(wait)
except RateLimitError:
time.sleep(60) # wait a full minute
raise Exception("All retries exhausted")Challenge 2: Cost
Every LLM call costs money. If your orchestrator loops without a limit, costs explode. Always set a budget:
MAX_TOKENS = 100_000 # $0.01-0.15 depending on model
def track_cost(prompt: str, response: str):
tokens = len(prompt.split()) + len(response.split())
if tokens > MAX_TOKENS:
raise Exception("Budget exceeded")Challenge 3: Safety
An autonomous agent can make bad decisions. You need guardrails - rules the agent cannot override:
GUARDRAILS = [
"Never delete data",
"Never send money",
"Never share user passwords",
"Ask before sending external emails",
]
def check_guardrails(step: Step) -> bool:
for rule in GUARDRAILS:
if rule.lower() in step.input.lower():
print(f"Blocked by guardrail: {rule}")
return False
return True7. Summary: What to Remember
- A chatbot answers questions. An orchestrator does work.
- The core loop is simple: for each step, run the tool, check the result, retry if needed, move on.
- Error handling is the most important feature. Orchestrators must self-correct, not crash.
- Production orchestrators add: LLM-based planning, parallel execution, state persistence, cost tracking, and safety guardrails.
- The hard problems are not AI problems - they are reliability, cost, and safety problems.
If you are new to this topic, here is your next step: take the minimal orchestrator from section 4, add one more tool (like a file writer), and give it a multi-step goal. Watch it plan, execute, and deliver. Once that works, add error handling. Then add retries. You will have built what takes most teams months to productionise.
# Your challenge: extend this orchestrator
# 1. Add a "write_file" tool
# 2. Give it a goal: "search for AI news and save to file"
# 3. Handle the case where search fails
# 4. Handle the case where the file already exists
def write_file(filename: str, content: str) -> str:
with open(filename, "w") as f:
f.write(content)
return f"Written to {filename}"
# Your orchestrator should:
# 1. Plan: search → write_file
# 2. Execute each step
# 3. Retry on failure
# 4. Report completion