← Articulet AI Agents, Made Clear Chapter 3.1
Module 3Tools, Memory, and Interoperability70-90 minutes.

Tool Engineering—Contracts, Permissions, and Side Effects

By the end, you can design a narrow tool contract, distinguish capability from authority, and protect side effects with validation, approval, idempotency, and receipts.

Chapter 7 of 2232% through the course
Module 3: Tools, Memory, and Interoperability

An agent becomes consequential at the tool boundary. Good tools make the useful action obvious, the forbidden action impossible, and every side effect attributable.

Mission

By the end, you can design a narrow tool contract, distinguish capability from authority, and protect side effects with validation, approval, idempotency, and receipts.

Prerequisites: Module 1 and Chapters 2.1-2.3. Basic Python and JSON help. Build artifact: a reviewed tool card plus an executable contract test. Time: 70-90 minutes.

Before you read: Predict → Commit → Connect

A travel agent can call one of these tools:

  • manage_booking(request: string)
  • hold_itinerary(booking_id, option_id, expires_in_minutes)

Which is safer? Write the strongest argument for the other choice. Then answer: if a call is valid JSON, does that mean the current user is allowed to execute it?

A tool is an authority boundary

A tool is an application-owned interface through which a model may propose a bounded operation. It has two faces:

  • the model sees a name, description, and input contract;
  • trusted application code authenticates, authorizes, validates, executes, records, and returns a bounded observation.

The model-facing schema helps selection. It does not grant permission. A tool call is untrusted input, even when a provider generated it in strict structured form.

Diagram showing Model proposes a named call; Schema valid?; No; Reject or bounded repair; Yes; Actor authenticated?.

Every arrow is owned by ordinary software. The model proposes; it never authenticates itself, widens its own scope, or decides that approval happened.

Design from the effect backward

Begin with the external effect, not with a clever function name.

For each tool, write a tool card:

Field Question
Purpose What single outcome does this tool produce?
Actor Which authenticated identity is acting?
Resource Which exact object may it read or change?
Preconditions What must already be true?
Inputs What minimum typed arguments are needed?
Invariants What must remain true regardless of the model request?
Side effect Is it read-only, reversible, compensatable, or irreversible?
Approval Who must approve which exact call, and for how long?
Idempotency What prevents a retry from applying the effect twice?
Result What structured observation and receipt return?
Limits Time, output, records, rate, and cost caps?
Owner Which team operates and revokes this capability?

If the card describes several unrelated effects, split the tool.

Names should reveal intent

Prefer verbs that distinguish stages:

  • search_itineraries reads candidates;
  • prepare_change_quote calculates a proposal;
  • hold_itinerary creates a temporary, reversible hold;
  • commit_booking_change performs the consequential transaction.

Avoid manage_booking, execute_task, run_query, or do_action. Broad names hide authority and produce overlapping choices.

Descriptions should say when to use the tool, when not to use it, and what it does not do. They are selection aids, not policy enforcement.

Make invalid states difficult to express

Use the narrowest contract that serves the task:

  • required fields instead of an open options object;
  • enums instead of unconstrained labels;
  • integer minor currency units instead of floating-point money;
  • bounded lengths and numeric ranges;
  • stable resource identifiers instead of natural-language descriptions;
  • additionalProperties: false when supported;
  • separate tools for proposal and commitment;
  • no raw SQL, shell text, URLs, or code unless the tool is explicitly a sandboxed interpreter.

This contract is stronger than {"request": "Please change the trip"}:

{
  "booking_id": "BKG-2041",
  "quoted_option_id": "OPT-318",
  "expected_quote_cents": 78450,
  "currency": "CAD",
  "idempotency_key": "change-BKG-2041-OPT-318-v1"
}

Code must still verify that the booking belongs to the actor, the quote is current, the currency matches, the option remains available, the approval covers this amount, and the idempotency key has not already committed a different request.

Four checks, four different questions

  1. Schema: Is the proposal shaped correctly?
  2. Semantic validation: Do IDs exist, values agree, and preconditions hold?
  3. Authorization: May this actor perform this operation on this resource now?
  4. Approval: Has an authorized person confirmed this particular consequential action?

Passing one never implies the others.

Capability is not authority

Tool discovery answers, “What interfaces exist?” Authorization answers, “What may this actor do in this task?” Keep them separate.

Construct the eligible tool set from authenticated identity, tenant, role, task phase, resource scope, and current policy. Do not expose every company tool and ask the model to behave.

Defense in depth continues inside the adapter. A hidden tool is not secure merely because its schema was omitted from the prompt. The service receiving the call must enforce authorization again.

Permission shape matters

Prefer:

  • one tenant over all tenants;
  • one booking over every booking;
  • read-only over read-write;
  • a five-minute scoped token over a standing credential;
  • a proposal tool over a commit tool;
  • explicit network destinations over unrestricted egress.

Never put credentials in prompts, tool descriptions, arguments, observations, or traces. Resolve short-lived credentials inside trusted execution code.

Side effects need transaction engineering

“The tool timed out” does not tell you whether the external write happened. Retrying blindly can send two emails, create two tickets, or charge twice.

Classify effects:

Class Example Default control
Pure/read-only Read one policy record Scope, validate, cap output
Reversible Create a temporary hold Expiry, receipt, verify state
Compensatable Create then later cancel a ticket Explicit compensation, reconcile
Irreversible/high consequence Transfer funds, disclose a secret Deterministic service, strong approval, often no agent autonomy
Diagram showing Proposed; Rejected; AwaitingApproval; Ready; Cancelled; InFlight.

An idempotency key identifies one intended effect. Repeating the same request with the same key returns the original outcome rather than creating another effect. It is not a random retry ID; it must remain stable across retries of the same intent.

For writes, record:

  • actor and tenant;
  • normalized arguments or a safe hash;
  • policy and approval IDs;
  • idempotency key;
  • start and finish times;
  • dependency response and external transaction ID;
  • final verification status.

That record is evidence. A cheerful model sentence is not.

Treat outputs as bounded, untrusted observations

A tool can return too much data, malformed data, stale data, secrets, or hostile instructions embedded in a document. Normalize results in trusted code:

{
  "tool": "policy_lookup",
  "call_id": "call-17",
  "ok": true,
  "source_version": "travel-policy-v18",
  "retrieved_at": "2026-07-17T15:05:00Z",
  "truncated": false,
  "untrusted_content": "..."
}

Cap records, bytes, execution time, and redirections. Return references to bulky artifacts rather than flooding context. Errors should be typed—NOT_FOUND, FORBIDDEN, RATE_LIMITED, DEPENDENCY_TIMEOUT—without leaking internals.

I do: inspect the course’s research tools

The offline laboratory defines two narrow tools: search a bounded corpus and read one document by ID. From the project root, run:

PYTHONPATH=courses/ai-agents/labs python3 - <<'PY'
import json
from agent_lab.capstones.research import build_registry

print(json.dumps(build_registry().schemas(), indent=2))
PY

Notice the bounded query length, result count, document ID, output size, and rejection of undeclared fields. The handlers receive normalized arguments; no dynamic evaluation occurs.

This registry still does not authenticate a real user because the laboratory is offline and synthetic. In production, identity and resource authorization belong before and inside the adapter.

We do: prove that structure rejects ambiguity

Predict the result of an unexpected include_secrets argument, then run:

PYTHONPATH=courses/ai-agents/labs python3 - <<'PY'
from agent_lab.capstones.research import build_registry
from agent_lab.contracts import ValidationError

registry = build_registry()
try:
    registry.prepare(
        "search_documents",
        {"query": "agent safety", "max_results": 2, "include_secrets": True},
    )
except ValidationError as exc:
    print(type(exc).__name__ + ":", exc)
else:
    raise SystemExit("FAIL: undeclared input was accepted")
PY

The contract catches an extra field before the handler runs. Now answer: which additional layer would reject a perfectly shaped request from the wrong tenant? Authorization, not JSON Schema.

You do: LAB-7 — Build and attack a tool card

Choose one read operation from your architecture memo. Keep it synthetic and non-sensitive.

  1. Write its tool card.
  2. Give it one purpose, a precise name, and no optional “future” capability.
  3. Define typed parameters with bounds and enums.
  4. Build it in memory with ToolSpec, ToolParameter, and ToolRegistry from courses/ai-agents/labs/agent_lab/tools.py.
  5. Prepare and invoke one valid call; then test a missing field, an extra field, a wrong type, an over-limit value, and an unknown tool.
  6. Cap its output and return a stable source ID.
  7. Explain what production authentication and authorization remain outside this lab.

Use this safe skeleton:

PYTHONPATH=courses/ai-agents/labs python3 - <<'PY'
import json

from agent_lab.contracts import ValidationError
from agent_lab.memory import SessionMemory
from agent_lab.tools import ToolContext, ToolParameter, ToolRegistry, ToolSpec

registry = ToolRegistry(max_tools=2)
registry.register(ToolSpec(
    name="lookup_policy",
    description="Read one synthetic policy section by approved identifier; never changes data.",
    parameters=(
        ToolParameter("section_id", str, min_length=3, max_length=12),
        ToolParameter("locale", str, choices=("en-CA", "fr-CA")),
    ),
    handler=lambda args, _ctx: {
        "source_id": args["section_id"],
        "locale": args["locale"],
        "text": "Synthetic policy text for contract testing.",
    },
    max_output_chars=500,
))
registry.register(ToolSpec(
    name="read_large_policy",
    description="Return an oversized synthetic policy result to test the output boundary.",
    parameters=(),
    handler=lambda _args, _ctx: {
        "source_id": "POL-LARGE",
        "text": "x" * 2_000,
    },
    max_output_chars=200,
))

valid = {"section_id": "POL-18", "locale": "en-CA"}
spec, normalized = registry.prepare("lookup_policy", valid)
context = ToolContext("lab-7", SessionMemory("lab-7"))
execution = registry.invoke(spec, normalized, context)
payload = json.loads(execution.output)
assert payload["source_id"] == "POL-18"
assert len(execution.output) <= 500
assert execution.truncated is False
print("VALID", dict(normalized), payload)

large_spec, large_args = registry.prepare("read_large_policy", {})
large_execution = registry.invoke(large_spec, large_args, context)
assert large_execution.truncated is True
assert len(large_execution.output) <= 200
assert json.loads(large_execution.output)["truncated"] is True
print("BOUNDED", len(large_execution.output), large_execution.truncated)

attacks = [
    ("lookup_policy", {}),
    ("lookup_policy", {"section_id": "POL-18", "locale": "en-CA", "admin": True}),
    ("lookup_policy", {"section_id": 18, "locale": "en-CA"}),
    ("lookup_policy", {"section_id": "POL-18", "locale": "all"}),
    ("lookup_policy", {"section_id": "POLICY-TOO-LONG", "locale": "en-CA"}),
    ("missing_tool", valid),
]
for name, attack in attacks:
    try:
        registry.prepare(name, attack)
    except ValidationError as exc:
        print("REJECTED", exc)
    else:
        raise SystemExit(f"FAIL: accepted {name} {attack!r}")
PY

Done when: all invalid proposals are rejected before execution, the valid result is bounded and attributable, and your tool card names the controls a schema cannot provide.

Pause & Recall

Close the page and answer:

  1. Why is a tool call untrusted even when it satisfies a strict schema?
  2. What four checks separate proposal from execution?
  3. How do idempotency and read-after-write verification solve different problems?
  4. Why should proposal and commitment usually be separate tools?
  5. What should a normalized observation contain?

Production lens

Maintain a tool inventory with owners, data classes, downstream systems, scopes, side-effect classes, approval rules, and revocation paths. Version contract changes and rerun selection, argument, authorization, and failure evaluations before release.

Alert on unusual tool discovery, repeated denials, argument-validation spikes, new destinations, output-volume changes, approval bypass attempts, and mismatched idempotency receipts. Rotate credentials without changing prompts. Disable one tool independently of the whole agent.

Do not let the model compose privileged primitives into an unreviewed transaction. “Read customer,” “calculate amount,” and “send payment” may each look narrow while their combination creates a high-consequence capability. Evaluate workflows, not only individual calls.

Chapter compression

  • A tool is an application-owned authority boundary, not a model superpower.
  • Schemas constrain shape; semantics, authorization, approval, and policy remain separate.
  • Design narrow names and contracts from the external effect backward.
  • Separate reads, proposals, reversible steps, and irreversible commitments.
  • Protect retries with stable idempotency keys, receipts, and state verification.
  • Bound and label tool output as untrusted observation.

Memory hook: Name it, narrow it, authorize it, verify it.

Retrieval deck

  • Q: What does a valid tool schema prove?
    A: Only that proposed arguments satisfy the declared structural constraints.
  • Q: Where should authorization be enforced?
    A: In the control plane before execution and again in the action service or adapter.
  • Q: What does an approval cover?
    A: One understandable, exact action with concrete arguments, impact, actor, and expiry—not standing permission.
  • Q: What should happen after a write times out?
    A: Reconcile using the idempotency key and external state or receipt; never retry blindly.

Spaced review

  • Now: complete the tool card for hold_itinerary.
  • +1 day: recall the four validation layers without looking.
  • +3 days: split one broad tool into read, propose, and commit stages.
  • +7 days: design a crash-after-write test and expected receipt behavior.
  • +14 days: threat-model a combination of three individually narrow tools.

Sources and further study

Keep your retrieval practice honest. Progress is saved only in this browser.