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.
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 stepsWhen 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.
- 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 - 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 - 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} - 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." } } - 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 - 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'))" - 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 - 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/tests13 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.
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.