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

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.

About 75 minutes · All modules

What you'll have at the end: By the end your Kettle agent issues refunds under the written policy (automatic up to $100, a person approves above, duplicates, late requests and over-total amounts denied), and runs unchanged on a scripted, a rule-based or a live model.

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.

In the kit: modules/02-frameworks-and-building-blocks

  1. 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"]
  2. 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"],
    }
  3. 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."
    }
  4. 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))
  5. 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
  6. 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
  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/02-frameworks-and-building-blocks/tests
  8. 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/tests

18 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.

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; KeywordModel and ScriptedModel make no API call. Optional live run, prices from config/prices.json as_of 2026-10-15 ($4/$20 per million input/output tokens for claude-opus-5-5, $0.10/$0.50 for claude-haiku-5-5): one refund ticket (three model calls) rehearses at about $0.011 on Opus and $0.0003 on Haiku. Live runs use more tokens than the rehearsal; plan on roughly $0.03 per ticket on Opus and under $0.001 on Haiku, about $0.10 for the three tickets in step 8.
Governance checkRefunds pass through argument and fact rules: $100.00 or less is allowed, above $100 waits for a person, and duplicates, requests past 30 days, amounts over the order total and refunds whose facts could not be checked are denied. Facts come from the store, never from the model, and the store refuses a second refund even if the policy were bypassed.

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.

Knowledge check

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

1/5 Because of the model adapter, what can you change without touching the agent loop?
2/5 T-016 asks for a $129 refund on a delivered order, 25 days old, never refunded. What does the module 2 policy decide, and what happens to the money?
3/5 An order already has a partial refund and the customer asks for $129 more. The duplicate rule (deny) and the over-100 rule (ask) both match. What is the decision?
4/5 Why do already_refunded and days_since_delivery come from a facts callback that reads the store, not from the model's tool arguments?
5/5 What does the Model Context Protocol (MCP) standardise?