Make Your Agent Quote a Contract, Not Call a Tool
Speaking session at PlatformCon 2026


Agents are in production and doing privileged things, but the guardrails did not arrive with them. This PlatformCon talk lays out a Java and Spring Boot pattern that makes an agent quote a versioned tool contract before any tool runs, with a gateway that validates, governs, and audits every call.
The agent era arrived. The guardrails did not. Agents are in production right now placing orders, querying secrets, and shipping code, and most teams put them there without a control plane. This talk is about the smallest change that buys one back: make the agent quote a versioned contract instead of calling a tool directly.
A short note on the talk
This was my PlatformCon 2026 session, "Tool Contracts for AI Agents." PlatformCon is one of the larger gatherings for platform engineers, and that shaped how I pitched it: not as an AI talk, but as a platform talk that happens to be about agents. The audience owns control planes, policy, and audit for a living, and the argument I wanted to land is that agent tool access is just another thing that belongs behind a policy decision point. The full recording is above, the slides are right below it, and what follows is the written version.
The problem: the guardrails did not arrive with the agents
The uncomfortable part is that agents in production are not chatbots. They place orders, they query secrets, they ship code. Every tool call is a privileged action against a real API, touching real money and real PII. And most teams ship them without a control plane, on the assumption that authentication is enough.
Auth is necessary and nowhere near sufficient. Auth tells you who is calling. It says nothing about what they are allowed to do with this particular tool, in this particular version, under this particular policy. What is missing is a policy decision point that sits between the agent's intent and the tool's execution, and that is tool-aware, versioned, and auditable.
Three failure modes auth alone will not catch
The talk names three, because they are the ones platform teams keep getting surprised by.
- Injection. Adversarial input convinces the agent to call a tool it should not. This is OWASP LLM-01, and no amount of correct authentication stops it, because the caller is legitimately authenticated. The problem is the action, not the identity.
- Drift. The agent is on v1. The tool team quietly shipped v2 with a new required field. Nothing errors loudly; you get a silent shape mismatch that surfaces as strange behavior later. Auth has no opinion about schema versions.
- Scope creep. A tool used safely in one path gets reused in a higher-trust path with no second look. It was fine where it started and dangerous where it ended up, and nothing forced a review at the boundary.
The gap in all three is the same. It is not more auth. It is tool-aware policy.
The pattern: quote a contract, do not call a tool
The core move is a one-line change at the call site. Instead of the agent calling tools.call("prom_query", { query, range }) directly, it invokes a gateway and quotes a contract: a contract id and a version, the tool it wants, and the arguments. The id and version together are the lock. The gateway resolves what actually runs, enforces the policy attached to that key, and writes exactly one audit row per call. The before has no policy decision point, no version pin, and no structured audit. The after has all three, for the cost of changing how the call is expressed.
The data model: eight fields, three buckets
A contract is small and readable on purpose. Eight fields in three groups:
- The lock:
idandversion. Together they uniquely identify a contract. A different version is a different contract, full stop. - The call:
tool,input_schema, andlimits. What runs, what shape the input must take, and how much it is allowed to cost. - Accountability:
required_scopes,owner, andaudit_class. Who may use it, who owns it, and how loud the audit gets.
Everything except the lock is just policy attached to that key. In practice a contract is a small piece of YAML living in git, reviewed in pull requests, and hot-reloaded by the gateway when it changes. The schema is not documentation; a validator runs it on every call. Schema is policy.
How it runs: one new component, seven checks
The architecture is deliberately boring, which is the point. There is an agent runner that calls /v1/invoke, a Contract Gateway written in Spring Boot on Java 21, the tool APIs behind it, and a contract registry that is just YAML in git with hot reload. Only the gateway is new code. The tools and the APIs stay exactly as they are.
Every call runs a validation pipeline ordered from cheap to expensive, and the first failure wins, each with its own distinguishable HTTP status:
- Token decode → 401 if the caller is not who they claim.
- Contract resolution → 404 if the contract is unknown, 409 if the version has drifted.
- Tool match → 403 if the contract does not bind the requested tool.
- Scope check → 403 if the caller lacks the required scopes.
- Schema validation → 400 if the arguments are the wrong shape.
- Limits and rate → 429 if it is too big or too frequent.
- Forward and audit → 200, the call goes through and one audit row is written.
Because the codes are distinct, a caller always knows why it was stopped, and a platform team can chart denials by stage.
Four demos, four ways to be stopped
The talk walks four live scenarios, and the recap slide lines them up:
- Happy path. A valid
metrics.v1call passes all seven checks, forwards to the tool, and writes a routine audit row. HTTP 200. - Contract mismatch. An adversarial agent holding
metrics.v1tries to callvault.read_secret. It fails at step 3, tool match, before the scope check even runs. Defense in depth: the contract id alone forbids the tool. HTTP 403. - Version drift. The tool team shipped
metrics.v2with a newtenant_idfield; the agent is still on the now-deprecated v1. The gateway returns HTTP 409 with the current version and a link to the migration doc. - Schema violation. The agent passes
rangeas a string instead of an object. The validator returns a JSON Pointer, the expected versus actual type, and a hint. The error itself is documentation. HTTP 400.
That last idea is the one I care most about: a good denial is not a dead end, it is a machine-readable explanation of exactly what to fix.
What you get for free, and how to roll it in
Because every call flows through one gateway, observability comes along for free: an OpenTelemetry span per validation step, an audit log that stores a payload hash and never the raw body, platform metrics for deny rate and drift and the top schema failures, and health and readiness that stay stale-but-readable if the registry has an outage.
Rollout is designed to be low-drama, in four phases:
- Shadow. The gateway sees traffic and denies nothing, so you can measure before you enforce.
- First contract live. One team, the lowest-risk tool first.
- Team-by-team opt-in. Expand by audit class.
- GitOps promotion. Moving a contract from v1 to v2 is a pull request with a
deny_afterdate, so migrations are scheduled, reviewed, and visible.
Three things to remember
If nothing else survives the talk, these three do:
- Make agents quote a contract. It is the smallest change that buys a real policy decision point.
- Versioning is non-negotiable. v1 and v2 are different keys, and once you accept that, drift becomes a solvable problem instead of a silent one.
- Audit gets easier when calls are typed. One row per call, with the decision and the reason captured, instead of trying to reconstruct intent from logs after the fact.
The recording and the full deck are above. This pattern is the same idea I keep coming back to in my work on governed tool access: the model can ask for anything, so the interesting engineering is in the layer that decides what actually runs.
