Merit AC™
Agentic Systems · Foundation · Module 1 of 14

Introduction to AI agents

What an agent is (model, tools, loop, stop conditions), when not to build one, and a first governed Kettle agent for order-status tickets.

About 60 minutes · All modules

What you'll have at the end: By the end you have the first working Kettle & Co. support agent: it answers the 12 order-status tickets offline, reads only the customer's own orders, escalates a missing parcel, and runs under a policy file, a scoped identity, a hash-chained audit log and a cost ledger with a budget cap.

What an agent is

An agent is a model in a loop with tools. The model reads the conversation and either answers or asks for a tool; the loop runs the tool, sends the result back, and asks again. That gives four parts, and you need all four: the model, the tools, the loop, and the stop conditions that end it.

Stop conditions are the easiest part to leave out. A model can keep asking for tools indefinitely, so every loop needs at least two exits: the model answered, or it ran out of steps. The kit's Agent adds three more: a budget cap, a refusal, and an answer cut off at the output limit.

The bare loop below is about twenty lines. Every agent framework is this loop with more built around it. You write it in step 2 of the lab, then switch to kettle.Agent, which is the same loop with governance switched on.

def minimal_loop(model: Model, tools: ToolRegistry, task: str, system: str = "", max_steps: int = 5) -> tuple[str, str, int]:
    """Run ``task`` until the model answers or ``max_steps`` calls. Returns (status, answer, steps)."""
    messages = [{"role": "user", "content": task}]
    for step in range(1, max_steps + 1):
        response = model.complete(system, messages, tools.schemas())  # 1. ask the model
        messages.append({"role": "assistant", "content": response.content})
        if not response.tool_calls:  # 2. stop condition: it answered
            return "done", response.text, step
        results = []
        for call in response.tool_calls:  # 3. run the tools it asked for
            try:
                output, is_error = json.dumps(tools.call(call.name, call.args), default=str), False
            except ToolError as err:
                output, is_error = str(err), True
            results.append({"type": "tool_result", "tool_use_id": call.id, "content": output, "is_error": is_error})
        messages.append({"role": "user", "content": results})  # 4. loop with the results
    return "max_steps", "", max_steps  # stop condition: out of steps

When not to use an agent

An agent decides its own steps, so it costs several model calls and you cannot list in advance what it will do. That is the price of flexibility, and most automation does not need it. A nightly refund report is a script. A fixed sequence of checks is a workflow. Sorting tickets into three queues is one model call.

Build an agent only when four things are true: the steps depend on the input and are hard to write down in advance; the result is worth several calls; a model can do the task well; and a wrong step can be caught and undone. If any of them is false, use something simpler.

Kettle & Co. support passes the test: tickets are free text and often mix two requests. It also starts safe. In this module the agent can read orders and hand a ticket to a person, nothing else, so a wrong step costs nothing. Refunds, which move money, arrive in module 2, behind a policy.

Governance from the first run

Governance added at the end of a project gets designed around whatever the agent already does. Here it is on before the first ticket: a policy file, a scoped identity, an audit log and a cost ledger.

The policy file names who the agent acts as (kettle-support-agent), the scopes that identity holds, and what each tool may do. Anything the file does not list is denied. Each tool declares the scope it needs, so a tool added in code without a matching scope cannot run.

Every model call, policy decision and tool result is written to a hash-chained audit log: each line carries the hash of the line before it, so editing any line breaks the chain from that point on. Every model call is also priced in a ledger from a dated price file, and a run that passes its budget stops before its next tool runs.

{
  "version": "m01-1",
  "identity": {
    "name": "kettle-support-agent",
    "scopes": ["orders:read", "tickets:escalate"]
  },
  "default": "deny",
  "tools": {
    "lookup_order": {
      "action": "allow",
      "reason": "Reading the customer's own order status is low risk."
    },
    "escalate": {
      "action": "allow",
      "reason": "Handing a ticket to a person is always allowed."
    }
  }
}

Offline first, with a price on every call

Every lab runs offline with ScriptedModel, which replays responses written in the lab. It is deterministic, so tests can assert exact outcomes, and it is free. A live model is one flag away and always optional.

Offline does not mean cost-blind. Give the scripted model a real model's price key and the ledger prices the same conversation from estimated token counts. For the 12 order-status tickets that rehearsal comes to about $0.07 at Claude Opus 5.5 prices, before you spend anything.

build_agent is the whole wiring: tools bound to the ticket's customer, the policy file, the audit log, the ledger and the limits.

def build_agent(
    model: Model,
    store: Store,
    customer_id: str,
    workdir: Path,
    budget_usd: float | None = 0.25,
    max_steps: int = 6,
    policy_path: Path = POLICY_PATH,
) -> Agent:
    """Wire the agent: tools bound to one customer, the policy file, an audit log and a ledger in ``workdir``."""
    workdir = Path(workdir)
    return Agent(
        model=model,
        tools=make_tools(store, customer_id),
        policy=Policy.load(policy_path),
        audit=AuditLog(workdir / "audit.jsonl"),
        ledger=Ledger(path=workdir / "ledger.jsonl"),
        system=SYSTEM_PROMPT,
        max_steps=max_steps,
        budget_usd=budget_usd,
    )

Lab: Build the first governed Kettle & Co. support agent and run it offline on the 12 order-status tickets.

In the kit: modules/01-introduction-to-ai-agents

  1. Run the finished agent offline. From agentic-systems/, run the solution on ticket T-001. The scripted model makes no API call, so it costs nothing and answers the same way every time. You get the reply, "[done] 2 model calls, $0.000000" and "audit log verified". Open runs/audit.jsonl and read the six records: run_start, model_call, tool_call, tool_result, model_call, run_end.
    python3 modules/01-introduction-to-ai-agents/solution/support_agent.py T-001
  2. Write the loop (TODO 2 and 3, starter/loop.py). Add the first stop condition (no tool calls means the model answered) and run the tools it asked for, returning every result in one user message. On a ToolError, send the message back with is_error set; the model reads it and recovers.
    def minimal_loop(model: Model, tools: ToolRegistry, task: str, system: str = "", max_steps: int = 5) -> tuple[str, str, int]:
        """Run ``task`` until the model answers or ``max_steps`` calls. Returns (status, answer, steps)."""
        messages = [{"role": "user", "content": task}]
        for step in range(1, max_steps + 1):
            response = model.complete(system, messages, tools.schemas())  # 1. ask the model
            messages.append({"role": "assistant", "content": response.content})
            if not response.tool_calls:  # 2. stop condition: it answered
                return "done", response.text, step
            results = []
            for call in response.tool_calls:  # 3. run the tools it asked for
                try:
                    output, is_error = json.dumps(tools.call(call.name, call.args), default=str), False
                except ToolError as err:
                    output, is_error = str(err), True
                results.append({"type": "tool_result", "tool_use_id": call.id, "content": output, "is_error": is_error})
            messages.append({"role": "user", "content": results})  # 4. loop with the results
        return "max_steps", "", max_steps  # stop condition: out of steps
  3. Write the order tool (TODO 1, starter/order_tools.py). lookup_order is bound to one customer when the tools are built, from the ticket's customer_id, which the helpdesk sets. Return the order when it belongs to that customer. When it is missing or belongs to someone else, raise the same ToolError either way, so a ticket asking about a neighbour's order learns nothing.
    def lookup_order(order_id: str) -> dict:
        order = store.order(order_id)
        if order is None or order["customer_id"] != customer_id:
            # Same message whether the order is missing or belongs to someone else: no leak.
            raise ToolError(f"No order {order_id} found for this customer. Ask the customer to check the order number.")
        return {key: order[key] for key in ORDER_FIELDS}
  4. Write the policy file (TODO 4, starter/policy.json). Allow the two tools, each with a one-line reason. Everything else stays denied by "default": "deny".
    "tools": {
      "lookup_order": {
        "action": "allow",
        "reason": "Reading the customer's own order status is low risk."
      },
      "escalate": {
        "action": "allow",
        "reason": "Handing a ticket to a person is always allowed."
      }
    }
  5. Switch on the audit log and the ledger (TODO 5, starter/support_agent.py). In build_agent, create AuditLog(workdir / "audit.jsonl") and Ledger(path=workdir / "ledger.jsonl") and pass them to Agent. Then run your agent on all 12 order-status tickets. Every ticket ends [done]: T-005 and T-006 ask for the order number, T-007 (someone else's order) gets the same "couldn't find" reply, T-009 (a missing parcel) is escalated, and T-012 answers the status and ignores the "admin mode" text inside the ticket.
    python3 modules/01-introduction-to-ai-agents/starter/support_agent.py --all
  6. Rehearse the cost, then try to cheat the audit log. Price the same scripted run as if it were Claude Opus 5.5, then edit one line of the log and verify it. The rehearsal prices all 12 tickets at about $0.07 from estimated token counts. After the edit, verify prints (False, 3): line 3 is the first tool_call record and its hash no longer matches. On macOS use sed -i ''.
    python3 modules/01-introduction-to-ai-agents/starter/support_agent.py --all --price-as claude-opus-5-5 --workdir /tmp/kettle-m01
    sed -i 's/"allow"/"deny"/' /tmp/kettle-m01/audit.jsonl
    python3 -c "from kettle import verify; print(verify('/tmp/kettle-m01/audit.jsonl'))"
  7. Run the tests. Run the module's tests against your starter files. They fail until every TODO is done.
    KIT_TARGET=starter python3 -m unittest discover -s modules/01-introduction-to-ai-agents/tests
  8. Optional: run it live. Needs your own Anthropic API key, read from the environment and never logged. The default budget cap stops any ticket at $0.25.
    export ANTHROPIC_API_KEY=...   # your key; never commit it
    python3 modules/01-introduction-to-ai-agents/solution/support_agent.py T-001 T-009 --live

Check your work:

cd agentic-systems
python3 -m unittest discover -s modules/01-introduction-to-ai-agents/tests

13 tests pass, including a tampered audit line caught at line 3, an unlisted tool denied without running, a missing scope denied, and a budget overrun stopped before the tool runs.

The free modules' lab files are in the kit. Get the kit with the series, or follow the steps above in your own project.

What it costsOffline: $0; the scripted model makes no API call and the ledger prices it at zero. Optional live run on claude-opus-5-5 at $4 input and $20 output per million tokens (config/prices.json, as_of 2026-10-15): the offline rehearsal estimates about $0.07 for all 12 tickets, and a live run uses more tokens (tool definitions and the model's thinking, billed as output), so plan on roughly $0.02 per ticket, about $0.25 for all 12. The default budget cap stops any single ticket at $0.25.
Governance checkFrom the first run the agent acts as a named identity with two scopes, under a default-deny policy file, with every model call, decision and tool result in a hash-chained audit log and every call priced in a ledger with a per-ticket budget cap. Tests prove a tampered log line, an unlisted tool, a missing scope and a budget overrun are each caught.

On your cloud

AWS
Amazon Bedrock serves foundation models from several providers behind one API, and Amazon Bedrock AgentCore provides a managed runtime, identity and observability for agent loops. AWS CloudTrail records the AWS API calls an agent's tools make.
Azure
Microsoft Foundry (formerly Azure AI Foundry) serves models and runs hosted agents through Foundry Agent Service. Azure Monitor and the Azure activity log record what those agents and their tools do.
Google Cloud
Vertex AI serves Google and partner models, and Vertex AI Agent Engine hosts and scales agent runtimes. Cloud Audit Logs record the Google Cloud API calls an agent's tools make.

Knowledge check

Pass with 4 of 5. Your result is saved in this browser.

1/5 Which four parts does every agent in this series have?
2/5 Kettle & Co. wants a CSV each morning totalling yesterday's refunds. What should you build?
3/5 Ticket T-007 asks about an order that belongs to another customer. Why does lookup_order raise the same "No order found" error it raises for a typo?
4/5 Someone changes "allow" to "deny" on line 3 of a six-line audit log. What does verify() return?
5/5 A ticket runs with budget_usd=0.05. The first model call costs $0.06 and asks for lookup_order. What happens?