Agent frameworks and building blocks
What frameworks add to the plain loop, the building blocks they share (model adapter, tool schemas, memory, MCP), and a refund tool under policy.
Frameworks or the plain loop
Agent frameworks package the loop from module 1 with extras: adapters for many model providers, tool schemas generated from function signatures, retries, conversation memory, tracing, multi-agent graphs and hosted runtimes. They save time when you need those extras and you accept the dependency, its abstractions and its upgrade schedule.
This series uses a plain loop, kettle/agent.py, so you can read every line that decides what the agent does. What you learn transfers: every framework has the same parts. When you evaluate one, find where it puts the model adapter, the tool schemas, the memory, the policy check, the audit trail and the cost meter, and check that it has all six.
The model adapter
The loop never calls a vendor SDK. It calls complete(system, messages, tools) on whatever model it was given and gets a ModelResponse back. Anything with a name, a price_key and that method is a model, so a scripted model, a rule-based model and a live API model are interchangeable.
The price key tells the ledger which row of config/prices.json to bill. That is how the same conversation can be rehearsed at Claude Opus 5.5 prices offline, and why swapping to a cheaper model shows up in the ledger immediately: the same conversation costs exactly 40 times more priced as claude-opus-5-5 than as claude-haiku-5-5.
class Model(Protocol):
"""The adapter interface. ``price_key`` names the row in config/prices.json."""
name: str
price_key: str
def complete(self, system: str, messages: list[dict], tools: list[dict]) -> ModelResponse: ...Tool schemas and scopes
Each tool declares its arguments as a JSON schema. The model reads the schema to know how to call the tool; the loop checks the model's arguments against the same schema before the policy is even asked, because a model's arguments are untrusted input. An amount of true, a missing reason, or an extra admin field is rejected and audited.
Each tool also declares the identity scope it needs. issue_refund needs refunds:write; until the policy file grants that scope, every refund is denied, whatever the rules say.
def make_refund_tool(store: Store, customer_id: str) -> Tool:
def issue_refund(order_id: str, amount: float, reason: str) -> dict:
order = store.order(order_id)
if order is None or order["customer_id"] != customer_id:
raise ToolError(f"No order {order_id} found for this customer.")
try:
return store.record_refund(order_id, amount, reason)
except ValueError as err:
raise ToolError(f"Refund not recorded: {err}") from None
return Tool(
name="issue_refund",
description=(
"Refund part or all of one of this customer's orders, in US dollars. "
"Refunds over $100 are queued for a person to approve; tell the customer when that happens."
),
params={
"type": "object",
"properties": {
"order_id": {"type": "string", "pattern": r"KC-\d{5}"},
"amount": {"type": "number", "minimum": 0.01},
"reason": {"type": "string", "maxLength": 500},
},
"required": ["order_id", "amount", "reason"],
"additionalProperties": False,
},
handler=issue_refund,
scope="refunds:write",
)Memory and protocols
Short-term memory, in this kit, is the message list a run builds up: every model turn and every tool result, in order. Agent.run(task, history=...) continues an earlier conversation from it. Long-term memory, which survives between tickets, is module 7.
The Model Context Protocol (MCP) is an open standard for how a client discovers and calls the tools a server exposes (tools/list and tools/call). A tool defined once can then be used by any MCP client. ToolRegistry.mcp_tools() prints the agent's tools in the tools/list shape; module 10 serves them over a real transport.
def mcp_tools(self) -> dict:
"""The registry as an MCP ``tools/list`` result."""
return {"tools": [tool.mcp_schema() for tool in self._tools.values()]}Policy rules on arguments and facts
A policy rule can test what the model proposed (args.amount) and facts your application looked up (facts.order_total, facts.already_refunded). The model never supplies facts: a ticket that says "this order was never refunded" changes nothing, because the facts come from the store.
The decision is the strictest of the tool's base action and every rule that matches. Rules can tighten a decision but never loosen it, and their order does not matter, so a duplicate refund over $100 is denied, not queued. A fact that is missing denies the refund (fail closed).
{
"id": "over-total",
"when": {"field": "args.amount", "op": ">", "ref": "facts.order_total"},
"action": "deny",
"reason": "A refund cannot be more than the order total."
},
{
"id": "over-100",
"when": {"field": "args.amount", "op": ">", "value": 100},
"action": "ask",
"reason": "Refunds over $100.00 need a person to approve them."
}Lab: Add a refund tool under the written refund policy, then run the same governed agent on two different models.
- Give the identity the refund scope (TODO 1, starter/policy.json). issue_refund declares scope="refunds:write". Without that scope in the identity, every refund is denied before the policy is consulted. Add it to the identity's scopes.
"scopes": ["orders:read", "tickets:escalate", "refunds:write"] - Look up the facts (TODO 2, starter/refund_tools.py). The policy needs to know things the model must not be trusted to say: is the order this customer's, was it delivered, was it already refunded, how many days ago, and what was its total. The agent calls facts(tool, args) before every policy decision; return {} for any tool other than issue_refund.
order = store.order(args["order_id"]) if order is None or order["customer_id"] != customer_id: return {"order_found": False} return { "order_found": True, "delivered": bool(order["delivered_on"]) and order["status"] in ("delivered", "refunded"), "already_refunded": order["refunded_usd"] > 0, "days_since_delivery": store.days_since_delivery(args["order_id"]), "order_total": order["total_usd"], } - Write the refund rules (TODO 3, starter/policy.json). Turn data/refund-policy.md into seven rules. Deny: facts-missing (facts.order_found missing), not-found, duplicate, not-delivered, window (facts.days_since_delivery > 30) and over-total (args.amount greater than "ref": "facts.order_total"). Ask: over-100 (args.amount > 100). One of them:
{ "id": "duplicate", "when": {"field": "facts.already_refunded", "op": "==", "value": true}, "action": "deny", "reason": "This order already has a refund on record; an order is never refunded twice." } - Wire the tool and the facts into the agent (TODO 5 and 6, starter/support_agent.py). Add the refund tool to the registry, and pass facts=refund_facts(store, customer_id) to Agent.
tools = make_tools(store, customer_id) tools.add(make_refund_tool(store, customer_id)) - Swap the model behind the adapter (TODO 4, starter/adapters.py). Write make_model so "scripted", "keyword" and "anthropic:<model-id>" each return a model. Then run T-013 on the scripted model and on KeywordModel, a few rules over the ticket text with no network. Both end "[done] lookup_order=allow, issue_refund=allow" and refund $28.00.
python3 modules/02-frameworks-and-building-blocks/starter/support_agent.py T-013 --model scripted python3 modules/02-frameworks-and-building-blocks/starter/support_agent.py T-013 --model keyword - Run every refund ticket, then approve one yourself. --refunds runs T-013 to T-034. T-013 to T-015 are allowed ($100.00 exactly is automatic), T-016 to T-018 end [needs_approval] with nothing paid, T-019, T-020 and T-033 are denied as duplicates, T-021, T-023, T-032 and T-034 fall outside the window, and T-022 (day 30) is allowed. --approve asks you on the terminal instead of queueing. In T-028 the scripted model has been talked into refunding $450 on a $68 order by a ticket claiming manager approval; the policy denies it.
python3 modules/02-frameworks-and-building-blocks/starter/support_agent.py --refunds python3 modules/02-frameworks-and-building-blocks/starter/support_agent.py T-016 --approve python3 modules/02-frameworks-and-building-blocks/starter/support_agent.py T-028 --model scripted - 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/02-frameworks-and-building-blocks/tests - Optional: swap in a live model. Needs your own Anthropic API key. Same agent, same policy, a real model. Compare the $ figures: same tickets, different rows of config/prices.json.
export ANTHROPIC_API_KEY=... # your key; never commit it python3 modules/02-frameworks-and-building-blocks/solution/support_agent.py T-013 T-016 T-028 --model anthropic:claude-haiku-5-5 python3 modules/02-frameworks-and-building-blocks/solution/support_agent.py T-013 T-016 T-028 --model anthropic:claude-opus-5-5
Check your work:
cd agentic-systems
python3 -m unittest discover -s modules/02-frameworks-and-building-blocks/tests18 tests pass: the $100.00 and day-30 boundaries hold, duplicates beat ask, a refund over $100 pays nothing until a person approves, a model fooled into a $450 refund is denied, and the scripted and keyword models reach the same governed outcome.
On your cloud
- AWS
- Amazon Bedrock's Converse API gives one request shape, including tool use, across the models Bedrock hosts, which is the job a model adapter does. Amazon Bedrock AgentCore Gateway can present existing APIs to agents as MCP tools.
- Azure
- Microsoft Foundry Models serves models from several providers through a common inference API, so client code can switch models without a rewrite. Azure API Management can front the tool APIs an agent calls, with authentication and rate limits.
- Google Cloud
- Vertex AI Model Garden offers Google, partner and open models, including Claude, behind Vertex AI endpoints. Google's open-source Agent Development Kit (ADK) can use MCP tools and deploys to Vertex AI Agent Engine.