Agentic AI · A short history

From hand-wired glue to agents that act

How software moved from fragile integrations to AI agents that use tools, and how evals and governance grew with each step.

Each chapter has four parts: the problem, the fix, how evals and governance changed, and an Under the hood panel with working code.

2005–2010Chapter 1

Hand-wired glue

Connecting systems one pair at a time.

CRM Billing Warehouse Support every pair needs its own code CRM Billing API hub one interface per system
Left: custom links between every pair. Right: each system exposes one interface.

The problem

Every business system spoke its own dialect. Connecting two of them meant custom code, and each new system multiplied the work. Enterprise integration relied on heavy XML contracts that were slow to build and fragile to change.

The fix

Web APIs built on plain HTTP and JSON made a service something any developer could call in an afternoon. Each system published one simple, reusable interface.

Evals and governance

Nothing was measured yet. Governance meant access control on the interface: who could call which endpoint, and whether the call was logged. Contracts (WSDL, OpenAPI) became the first written rules for what a system promises to do.

Under the hood, with code

A REST call, end to end

# request: read one order
GET /api/v1/orders/A-1042 HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Accept: application/json

# response
HTTP/1.1 200 OK
Content-Type: application/json

{"id": "A-1042", "status": "shipped", "total": 129.90}

The same call in Python:

import requests

r = requests.get(
    "https://api.example.com/api/v1/orders/A-1042",
    headers={"Authorization": "Bearer " + token},
    timeout=10,
)
r.raise_for_status()          # non-2xx becomes an exception
order = r.json()              # {"id": "A-1042", "status": "shipped", ...}

What SOAP looked like for the same job

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <GetOrder xmlns="urn:orders:v1">
      <OrderId>A-1042</OrderId>
    </GetOrder>
  </soap:Body>
</soap:Envelope>

The WSDL file generated client stubs from this shape. Every change to the contract regenerated and retested the stubs.

2010–2015Chapter 2

Software you rent

Cloud products that update for everyone at once.

Shipped version 1.0 Customer workarounds years pass, no feedback One product Users usage signals return
Left: a shipped product drifts from real work. Right: one product improves from usage signals.

The problem

Installed software shipped once and then drifted from how people worked. A central team could not see each organization's data or exceptions, so customers built workarounds.

The fix

Cloud delivery let one product improve continuously. Real usage began to shape the product directly, instead of arriving years later as an upgrade.

Evals and governance

Quality became a telemetry question: error rates, latency, and failed workflows per release. Governance moved into tenant isolation, audit logs, and role-based access, because one codebase now served many customers.

Under the hood, with code

Tenant isolation in a query

# every query carries the tenant; a missing filter leaks data across customers
def list_orders(db, tenant_id: str, status: str):
    return db.execute(
        "SELECT id, status, total FROM orders "
        "WHERE tenant_id = %s AND status = %s",
        (tenant_id, status),
    )

A feature flag rollout

if flags.enabled("new_invoice_view", tenant_id=tenant_id):
    return render_invoice_v2(invoice)   # 5% of tenants first, then more
return render_invoice_v1(invoice)

Structured log event

{"event": "workflow_failed", "tenant": "t_204", "step": "invoice.send",
 "error": "smtp_timeout", "release": "2014.06.3", "ms": 30012}
2017–2020Chapter 3

Models that read

One pretrained model, adapted to many language tasks.

Classifier Translator Extractor one model per task Pretrained model Classify Translate Extract one model, prompted per task
Left: a labeled dataset and a new model for each task. Right: one pretrained model, steered by the prompt.

The problem

Each task such as classification, translation, or extraction needed its own model and labeled data. A new use case meant a new project.

The fix

Large pretrained models learned general language first. One model could then be steered to many tasks through a prompt or a small amount of data.

Evals and governance

Benchmarks such as GLUE became the shared yardstick, and that created a new risk: a model can score well on a benchmark and fail on your data. Evals had to move from the lab to each team's own test cases.

Under the hood, with code

Masked-word prediction, the BERT objective

from transformers import pipeline

fill = pipeline("fill-mask", model="bert-base-uncased")
for p in fill("Invoices are due within thirty [MASK]."):
    print(p["token_str"], round(p["score"], 3))
# days 0.6 ...  (scores are the model's probabilities for each candidate)

Self-attention, the core operation

import numpy as np

def attention(Q, K, V):
    d_k = K.shape[-1]
    scores = Q @ K.T / np.sqrt(d_k)           # how strongly each token attends to each other token
    weights = np.exp(scores - scores.max(-1, keepdims=True))
    weights /= weights.sum(-1, keepdims=True)  # softmax per row
    return weights @ V                       # weighted mix of value vectors

Adapting by prompt instead of retraining

prompt = """Classify the ticket as billing, technical, or other.
Ticket: My card was charged twice.
Label:"""   # one model, no new training run
2022Chapter 4

Chat that cannot act

Fluent answers, from memory, with no access to your data.

Question Model (memory) Confident, unchecked answer no files, no tools, no source Question Retrieve Model + passages Cited answer
Top: the model answers from memory. Bottom: relevant passages are retrieved first, and the answer cites them.

The problem

Chat assistants answered from training alone. They could not see your files or take action, and they stated wrong answers with the same confidence as right ones.

The fix

Retrieval-augmented generation (RAG) fetches the passages that matter from your documents and gives them to the model with the question. Answers can cite sources, and a wrong fact is fixed by editing the documents rather than retraining.

Evals and governance

Two checks became standard: retrieval evals (did the right passage come back?) and faithfulness evals (does the answer stay inside the passages?). Citations turned answers into something a reviewer can verify.

Under the hood, with code

A complete RAG loop in about 30 lines

import numpy as np

docs = {
    "refund-policy": "Refunds are issued within 14 days of purchase.",
    "shipping": "Orders ship within 2 business days.",
}
# stand-in embedding: swap in a real embedding model in production
def embed(text):
    v = np.zeros(256)
    for ch in text.lower():
        v[ord(ch) % 256] += 1
    return v / np.linalg.norm(v)

index = {doc_id: embed(text) for doc_id, text in docs.items()}

def retrieve(question, k=1):
    q = embed(question)
    ranked = sorted(index, key=lambda d: -float(q @ index[d]))  # cosine similarity
    return ranked[:k]

def build_prompt(question):
    ids = retrieve(question)
    context = "\n".join(f"[{i}] {docs[i]}" for i in ids)
    return f"Answer only from the sources. Cite the id.\n{context}\nQ: {question}"

print(build_prompt("How long do I have to get a refund?"))

Send the printed prompt to any chat model. The model sees only the retrieved passage, and the citation id ties each claim back to the document.

Where it fails

  • Bad chunking splits the answer across pieces. Weak retrieval means the right text never reaches the model.
2023Chapter 5

Tools and loops

The model picks an action, the system runs it, and the result returns to the model.

Step 1 Step 2 Step 3 fails fixed chain: no recovery Plan Act Observe
Left: a fixed sequence stops when a step fails. Right: the agent loop plans, acts, observes, and plans again.

The problem

A single prompt cannot reliably carry out a multi-step job. Hand-written chains broke whenever one step returned something unexpected.

The fix

Models request named tools with typed inputs, read the results, and decide the next step until the task is done. The system, not the model, decides which tools exist.

Evals and governance

Evals now score whole trajectories: did the agent pick the right tool, with valid arguments, in a bounded number of steps? Governance added permission levels per tool and a step limit, so a loop cannot run forever.

Under the hood, with code

Declaring a tool

tools = [{
    "name": "lookup_order",
    "description": "Return status and total for one order id.",
    "input_schema": {
        "type": "object",
        "properties": {"order_id": {"type": "string"}},
        "required": ["order_id"],
    },
}]

The loop, with the Anthropic Python SDK

import anthropic

client = anthropic.Anthropic()          # reads ANTHROPIC_API_KEY from the environment
MAX_STEPS = 8                            # governance: bounded loop
messages = [{"role": "user", "content": "Has order A-1042 shipped?"}]

def run_tool(name, args):
    if name == "lookup_order":
        return {"status": "shipped", "total": 129.90}
    raise ValueError(f"unknown tool {name}")

for step in range(MAX_STEPS):
    resp = client.messages.create(
        model="<your-model-id>", max_tokens=1024, tools=tools, messages=messages,
    )
    messages.append({"role": "assistant", "content": resp.content})
    if resp.stop_reason != "tool_use":
        break                       # model answered; done
    results = []
    for block in resp.content:
        if block.type == "tool_use":
            out = run_tool(block.name, block.input)    # the system executes the call
            results.append({"type": "tool_result",
                            "tool_use_id": block.id,
                            "content": str(out)})
    messages.append({"role": "user", "content": results})

print(resp.content[-1].text)

The model never runs code itself. It emits a request, the host executes it, and the result goes back as a message.

2024Chapter 6

One connector standard

Build each system's connection once, then reuse it with any agent.

Model A Model B Model C CRM Files Tickets 3 × 3 = 9 custom connectors Model A Model B Model C MCP one protocol 3 + 3 = 6 connections
Left: every model needs its own connector to every system. Right: each side speaks one protocol.

The problem

Each model, framework, and system combination needed a custom connector. The number of connectors grew faster than anyone could maintain.

The fix

The Model Context Protocol (MCP), introduced by Anthropic in November 2024, gives tools and data one shared interface. A system exposes its capabilities once as an MCP server, and any compatible client can use them.

Evals and governance

Tool descriptions became a governed interface. A client can see every tool a server offers before it runs one, so a reviewer can approve the list once, and each tool's schema documents what it may touch.

Under the hood, with code

The wire format: one tool call, as JSON-RPC 2.0

// client to server: discover tools
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}

// client to server: invoke one
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
 "params": {"name": "lookup_order", "arguments": {"order_id": "A-1042"}}}

// server to client
{"jsonrpc": "2.0", "id": 2, "result": {
  "content": [{"type": "text", "text": "{\"status\": \"shipped\"}"}],
  "isError": false}}

Writing the server once, with the official Python SDK

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("orders")

# the type hints and docstring become the tool's schema and description
@mcp.tool()
def lookup_order(order_id: str) -> str:
    """Return status and total for one order id."""
    return '{"status": "shipped", "total": 129.90}'

if __name__ == "__main__":
    mcp.run()          # stdio transport by default

Any MCP-capable client can now list and call lookup_order. Adding a second model means one more client, not a second connector.

2025–2026Chapter 7

Agents that keep working

Reusable know-how, isolated sub-tasks, schedules, and approval gates.

Session 1: rewrite instructions Session 2: rewrite instructions nothing carries over Skill (procedure) Sub A Sub B Approval gate Result + audit log Schedule: daily 08:00 procedure reused, work isolated, writes gated
Left: each session starts from scratch. Right: a saved procedure runs on a schedule, with isolated sub-tasks and an approval gate.

The problem

One-off sessions forgot what worked last time. Teams rewrote the same instructions, and nobody could easily check what an unattended agent had done.

The fix

Skills package a procedure once. Subagents keep large jobs from crowding one context. Schedules run recurring work, and approval gates keep a person responsible for actions that are hard to undo.

Evals and governance

Governance became a policy file, not a memo. Each action class is allowed, asked, or denied, and every run writes a trace. Evals run on a schedule against saved cases, so a skill that starts failing is caught before a person notices.

Under the hood, with code

A skill: a folder with a SKILL.md

refund-review/
├── SKILL.md
└── check_policy.py

---
name: refund-review
description: Review a refund request against the 14-day policy. Use when a customer asks for a refund.
---
1. Load the order with lookup_order.
2. Compute days since purchase. If over 14, deny and cite refund-policy.
3. If 14 or fewer days, draft the refund. Do not send it: stop at the approval gate.

Only the name and description are loaded by default. The steps load when a request matches.

A governance policy, as data

policy = {
    "lookup_order":  "allow",    # read-only
    "draft_refund":  "allow",
    "send_refund":   "ask",      # a person approves each call
    "delete_order":  "deny",
}
MAX_STEPS = 12

def gate(tool_name, args, ask_human):
    decision = policy.get(tool_name, "deny")       # unknown tools are denied
    if decision == "deny":
        return {"error": "not permitted"}
    if decision == "ask" and not ask_human(tool_name, args):
        return {"error": "rejected by reviewer"}
    return run_tool(tool_name, args)

Scheduling and tracing

# cron: run the review every day at 08:00
0 8 * * *  agent run refund-review --trace-dir /var/log/agent

# one trace line per tool call, so every action can be audited
{"run": "r-8831", "step": 3, "tool": "send_refund", "decision": "ask",
 "approved_by": "j.ruiz", "result": "ok", "ts": "2026-11-02T08:00:14Z"}
NowChapter 8

Evals and governance now

Capable models are easy to reach. The hard part is proving they work and keeping them in bounds.

Real cases Run agent Score Gate Deploy monitor traces, add failures to the case set
The loop that keeps a deployed agent correct. Every production failure becomes a new test case.

The problem

Getting a model to answer is no longer the difficulty. Proving a change did not break something, connecting to real systems, and keeping quality steady after launch are still hard.

The fix

Keep a set of real cases with expected outcomes. Run it on every change to prompts, tools, or models. Gate the release on the score, scope each tool's permissions, and trace every run back to the step that caused a failure.

Evals and governance

Evals are a release gate, not a one-time test. Governance is a written policy for each tool, a named owner for each agent, an approval path for irreversible actions, and an audit trail that a reviewer can read.

Under the hood, with code

A minimal eval harness

import json

cases = [json.loads(line) for line in open("cases.jsonl")]
# each line: {"input": "...", "must_call": "lookup_order", "must_contain": "shipped"}

def score(agent, case):
    run = agent(case["input"])                          # returns {"tools": [...], "answer": "..."}
    tool_ok = case["must_call"] in run["tools"]
    text_ok = case["must_contain"].lower() in run["answer"].lower()
    return tool_ok and text_ok

passed = sum(score(agent, c) for c in cases)
rate = passed / len(cases)
print(f"pass rate {rate:.0%} on {len(cases)} cases")

# release gate: block the deploy below the agreed threshold
assert rate >= 0.95, "eval gate failed: do not deploy"

Judging free-text answers

judge_prompt = """Given the source passage and the answer, reply PASS only if every claim
in the answer is supported by the passage. Otherwise reply FAIL and name the claim.

Passage: {passage}
Answer: {answer}"""   # run with a separate model, then spot-check the judge by hand

Tracing a failure

{"run": "r-9012", "steps": [
  {"tool": "lookup_order", "args": {"order_id": "A-1042"}, "ok": true},
  {"tool": "draft_refund", "args": {"order_id": "A-1042", "amount": 129.90}, "ok": false,
   "error": "amount exceeds refundable total"}
], "final": "I could not draft the refund."}

The trace shows which step failed and why. That line goes straight into the case set as a new regression test.

Glossary

  1. Eval: a repeatable test of model or agent behavior on defined cases, with a score.
  2. Faithfulness: whether an answer's claims are supported by the sources it was given.
  3. Function calling: a model requests a named tool with structured arguments; the system runs it and returns the result.
  4. RAG: retrieval-augmented generation; supplying relevant passages to the model at question time.
  5. MCP: Model Context Protocol; a shared way for AI applications to reach tools and data through servers.
  6. Governance: the written rules for what an agent may do, who owns it, who approves risky actions, and how its actions are recorded.

Dates and product facts are given from general knowledge as of mid-2026. Code is illustrative: check model names, SDK versions, and API signatures against current documentation before running it.