Agent architecture

Your agent reasoned correctly. It still shouldn't have been allowed to do that.

2026-08-24 Shiva Perumalsamy ~12 min read

The model proposes · the control plane authorizes · the tool executes

The argument in short~2 min

A model can be completely right about what should happen next and still be the wrong thing to authorize it. So let it reason freely, and take the decision away from it.

  1. Reasoning is not authority. Proposing an action and being allowed to take it are two different responsibilities. Most agent stacks collapse them.
  2. A tool call is a proposal, not an instruction. Make it a structured record — principal, agent, action, parameters, observed state — that something else evaluates.
  3. Put a deterministic control plane between reasoning and execution. Identity, authorization, policy, risk, approval, state validation, credential scope, audit. None of it in the model.
  4. MCP exposes capability, not policy. A tool server should know how to modify a reservation. It should not decide whether this agent may modify this one.
  5. Risk belongs to the action, not the tool. Reading a Jira issue and deleting one are not the same grant. Tool-level access is too coarse to express that.
  6. Approval is one verdict among several, not the policy system. Interrupt a human only when the risk earns it — ask about everything and they stop reading the prompts.
  7. Re-validate at execution, not at proposal. The world moves while the agent thinks. Check the assumptions at the boundary, and read back what actually happened instead of trusting a 200.
  8. An agent workflow is a distributed system. Partial failure, idempotency, compensation. The only genuinely new part is that the coordinator reasons.

The model proposes · the control plane authorizes · the tool executes · the evidence explains what happened.
The rest of this piece argues each point, then walks a real system end to end.

Give a language model a set of tools and it will use them. That is the appeal, and it is also the whole problem. The moment an agent can call reservation.modify or issue.delete or instance.terminate, a probabilistic system is holding authority over a deterministic one — and correct reasoning is not the same thing as permission.

The architecture I keep arriving at separates those two ideas completely. The model decides what to propose — that is planning, and it is the part it is genuinely good at. What it does not decide is whether the proposal is authorized to run. Every tool call it produces is a proposal, and a separate deterministic layer rules on it.

The model proposes. The control plane authorizes. The tool executes. The evidence explains what happened.

That sentence is four responsibilities, and the interesting engineering is in refusing to collapse them into one system.

Where the boundary goes

Most agent stacks today have two layers: a model, and a set of tools it can reach — often through MCP. The tool call travels straight from reasoning to execution. Nothing sits in between with the authority to say no.

The layer that is missing is a control plane: deterministic code, holding identity, policy, risk classification, and state, positioned so that every proposed action has to pass through it.

Three-plane agent architecture A reasoning plane containing the language model emits a proposal downward into a deterministic control plane holding identity, authorization, tool registry, policy, risk classification, approval, state validation and credential isolation. The control plane emits an authorized action downward into an execution plane of MCP tool servers. Evidence — trace, audit and evaluation data — flows back up from execution to reasoning. REASONING PLANE probabilistic LLM agent interprets context · compares options · proposes the next action proposal — not an instruction CONTROL PLANE deterministic identity authorization tool registry policy risk class approval state validation credential scope every decision records who asked, what was asked, which policy applied, and why authorized action + scoped credential EXECUTION PLANE capability only restaurant mcp calendar mcp rides mcp messaging mcp evidence · trace · audit · eval
The control plane is the only layer that decides whether a proposal becomes an authorized action. The layers below it are deterministic too — they just do not get a vote.

A proposal is just a structured record, and writing it down explicitly is what makes the rest of the design possible:

principal:   user-123
agent:       daily-planner
tool:        restaurant
action:      reservation.modify
parameters:
  reservation_id: 8472
  requested_time: 20:00
risk:        medium
observed_at: 2026-08-24T18:04:11Z
trace_id:    abc-123

Once the call is a record rather than a function invocation, the control plane can ask questions the model has no business answering about itself. Is this principal authenticated? Does reservation 8472 belong to them? Is this tool registered and trusted? Is this agent permitted to use this action, as opposed to this tool? Does this action require approval? And — the one most systems forget — is the world still in the state the agent observed when it made the proposal?

MCP exposes capability. It should not own policy.

The obvious place to put all this is the tool server, and it is the wrong place. A restaurant MCP server should know how to modify a reservation. It should not know your identity model, your approval matrix, your risk appetite, or how the other eleven tools in the system are supposed to behave.

Push policy into tools and you rebuild the same governance layer once per tool, slightly differently each time, and you find out where the copies disagree during an incident. The tool's job is narrow on purpose: expose a capability safely and consistently, and refuse to be interesting.

Narrow is not passive. A tool server still validates its own inputs against its schema and enforces whatever provider-side constraints its API demands — that is local correctness, and you want it as close to the call as possible. What it should not carry is the cross-cutting decision: whether this principal, through this agent, may take this action right now. The first is defense in depth. The second is a policy engine that has been copy-pasted eleven times.

Risk belongs to the action, not the tool

Most agent stacks grant access at the tool level: the agent can reach the restaurant server, therefore it can reach everything that server exposes. That is far too coarse, because the consequences inside a single tool are not remotely uniform.

ActionRiskDisposition
restaurant.searchlowExecute automatically. Reversible, no side effects, no state change.
restaurant.availabilitylowExecute automatically. Read path, cacheable, safe to retry.
reservation.modifymediumExecute under defined conditions — ownership proven, state revalidated, within a standing authorization.
reservation.cancelhighRequire explicit approval. Destructive, hard to reverse, socially visible.

A useful policy model grants authority at the action boundary, not the tool boundary. The same gradient runs through every enterprise system I have worked in. Reading a Jira issue and deleting one are not equivalent. Looking up a customer record and changing their account are not equivalent. Viewing infrastructure and terminating it are emphatically not equivalent. As agents get more capable, the granularity of that distinction is what stands between a useful system and an incident report.

Three decisions, three owners

Granting the model tools is usually described as one decision. It is actually three, and collapsing them is where most of the coupling comes from.

DecisionOwnerWhy there
WHATmodelWhich business capability does the situation call for? This is judgement over messy context — exactly what the model is good at.
WHEREregistryWhich provider owns this merchant or this existing booking? Deterministic lookup, never visible to the model.
HOWadapterNative MCP, REST, GraphQL, or something worse. A per-provider concern that nothing upstream should know about.

Keeping WHERE and HOW away from the model buys something concrete: the orchestrator reasons in canonical capabilities — dining, mobility, itinerary — and never in provider-specific schemas. Swapping a booking provider, or adding one, changes an adapter and a registry row. The reasoning layer does not move.

It also closes an exploit. If the model cannot name a provider, it cannot be talked into choosing a different one mid-execution. Provider binding is resolved server-side, after authorization, and stays fixed for the life of the action.

Approval is a policy outcome, not the policy system

The reflex answer to all of this is to ask the user before every write. It sounds safe and it is quietly corrosive. An agent that interrupts every few minutes trains people to stop reading and start clicking, and human-in-the-loop degrades into human-as-a-button. You end up with a complete audit trail of decisions nobody actually made.

The goal is not to put a human in front of every action. It is to interrupt the human only when the risk justifies the interruption.

Which means approval has to be one possible verdict among several — execute, execute within an existing authorization, escalate for confirmation, deny — chosen by policy rather than applied uniformly out of caution. When a human does confirm, that confirmation becomes an input to the policy gate. It is not a way around it.

Make the governance unskippable

There is a tempting shortcut here: expose policy as one more tool and let the agent call it before acting. That design fails the moment the model forgets, reorders, or reasons its way past the check — and you will not find out until something has already happened.

So policy does not sit beside the mutation, it sits inside it. Every consequential write runs schema validation, the policy engine, the idempotency check, and the audit write on the path to the provider, in code the model cannot address or skip. The control is structural. Nothing depends on the model remembering it exists.

Validate state at execution, not at proposal

Agents are slow in a way that matters. They gather context, reason, call several tools, and sometimes wait on a human. The world does not pause for any of it.

An agent checks availability and sees an 8:00 PM slot. It proposes the change. The notification reaches me five minutes later and I approve it. In those five minutes someone else took the last 8:00 slot — or I moved the booking myself from the restaurant's own app, or a second agent acting on the same plan got there first. The proposal was valid when it was created and is invalid when it executes — and if the system trusts the approval instead of rechecking the world, the approval has actively made things worse by lending confidence to a stale decision.

This is time-of-check to time-of-use, and it is not an AI problem. It is a concurrency problem that agents simply make much easier to hit, because the gap between deciding and acting went from milliseconds to minutes. The fix is the ordinary one: carry the observed state into the proposal, revalidate it at the execution boundary, and fail closed when the assumptions no longer hold.

For anything consequential I run the full sequence rather than a single call:

hold        → take the inventory so it cannot vanish mid-decision
mutate      → execute once, under an idempotency key
read back   → ask the provider what state it is actually in now
audit       → record what was asked, allowed, executed, and confirmed

The read-back is the step people skip, and it is the one that matters most. A 200 means the request was accepted, not that the world now looks the way you assume. Reporting success on the strength of a status code is how an agent tells someone their evening is sorted when it is not — and a confident wrong answer about the real world costs far more trust than an error message.

An agent workflow is a distributed system

Once an agent coordinates more than one tool, the shape becomes familiar. Move the reservation, update the calendar, notify the other person, adjust the ride. Steps one and two succeed. Step three fails.

There is no transaction spanning a restaurant API, a calendar provider, a messaging service, and a ride-sharing platform. What there is instead: partial failure, retries, idempotency, compensating actions, and stale state. If a call times out, did the update fail, or did it succeed and the response get lost? Retry without an idempotency key and the agent may book dinner twice.

None of that is new. The only genuinely new thing is the coordinator — it used to be deterministic application code, and now it is a model reasoning about what should happen next. That makes these patterns more important than they were, not less.

What the control plane owns

Not one monolith. A set of responsibilities that live between reasoning and execution, wherever they physically run:

IdentityWho is the user, and which agent is acting on their behalf?
AuthorizationCan this principal, through this agent, invoke this action?
Tool registryIs this a trusted tool with a known schema and expected behavior?
PolicyUnder what conditions is this action allowed at all?
Risk classificationHarmless read, reversible write, or destructive operation?
ApprovalDoes this specific action warrant interrupting a human?
State validationAre the assumptions behind the proposal still true right now?
Credential isolationCan this execute without the model ever seeing provider credentials?
ObservabilityWhich agent called which tool, with what arguments, and what came back?
AuditCan we explain later who authorized this, and under which policy?
EvaluationWas the tool choice appropriate? Did policy behave correctly? Did the workflow achieve the intent?
CompensationWhen step three fails, what gets retried, reversed, or deliberately left alone?
RoutingWhich provider owns this action, and through which adapter — decided server-side, never by the model.
IntentHard constraints, soft preferences, and what must be preserved — held as data, not left in a prompt.

None of these are model responsibilities, and none of them should be duplicated inside every tool server.


Walkthrough: StillOn

The use case that made all of this concrete for me was small and personal. StillOn is a recovery layer for a plan that already exists. Booking platforms are excellent up to the moment of confirmation; almost nobody owns what happens after. A flight slips, a meeting overruns, an event is cancelled — and working out what is now unreachable, what could replace it, and who needs to be told gets handed straight back to the customer.

So StillOn does five things in a loop: detect a change, work out which downstream activities are now infeasible, search eligible partners for alternatives that preserve the original intent, act — automatically where it is pre-authorized and by asking where it is not — and verify the partner state before claiming anything worked.

The first architecture was the obvious one: context into a model, services attached through MCP, let it reason, let it act. It worked, which is exactly what made the authority question uncomfortable. Here is what it became.

StillOn reference architecture Six stages stacked vertically. Trigger: web app, assistant MCP surface, or a deterministic disruption monitor with no language model. Plan: the Maestro orchestrator, the only component that calls a model. Governed tools: canonical dining, entertainment, mobility, flight, itinerary and notification capabilities. Authorize: schema validation, policy engine, idempotency, typed errors and audit, none of it callable by the model. Route: a partner registry never visible to the model, resolving a certified adapter. Execute and verify: provider adapters over native MCP, REST or GraphQL. A state and runtime band underneath spans every stage, with PostgreSQL as authoritative truth and Redis holding locks and expiring holds. 1 · TRIGGER apps/web · guest plan UI assistant MCP · OAuth 2.1 disruption monitor · no LLM 2 · PLAN probabilistic Maestro orchestrator the only component that calls a model canonical governed tools only — no provider APIs 3 · GOVERNED TOOLS — DOMAIN MCP LAYER dining entertainment mobility flight itinerary notification 4 · AUTHORIZE — GOVERNED EXECUTION not model-callable schema validation · policy engine · idempotency · typed errors · audit 5 · ROUTE — DETERMINISTIC never LLM-visible partner registry → merchant → owning provider → certified adapter 6 · EXECUTE & VERIFY provider A · native MCP provider B · REST provider C · GraphQL STATE & RUNTIME — PARTICIPATES AT EVERY STEP, NOT A STEP PostgreSQL · authoritative truth: chain, workflow, approvals, audit Redis · non-authoritative: locks, cache, expiring holds
One model-enabled component, and five layers below it that are not. New providers arrive as an adapter and a registry row — the reasoning layer never learns their names.

Trace a delayed flight through it.

  1. DetectThe disruption monitor sees the inbound flight now lands at 19:20 instead of 17:45. The monitor is deterministic and contains no model — nothing about noticing a change requires one.
    auto · low risk
  2. ReasonMaestro — the one component wired to a model — reads the itinerary, sees a 19:30 dinner that is no longer comfortably reachable, and proposes moving it to 20:00. It reasons in capabilities, not providers: it asks for dining, and has no idea which booking platform is behind that restaurant.
  3. Check intentThe customer's constraints are stored as data, not left in conversation history. Hard constraints, soft preferences, and what must be preserved are all separable — so "keep the party together" outranks "prefer 20:00" when the two conflict.
    pass
  4. AuthorizeInside the mutation handler, not beside it: schema validation, then policy. Is the principal authenticated, does this reservation belong to them, is this agent permitted this action, does the risk class demand a human? A 30-minute shift falls inside a standing authorization; the model could not have skipped this check even if it tried.
    execute within authorization
  5. RouteThe partner registry maps merchant to owning provider to certified adapter. This happens after authorization, server-side, invisible to the model — and once bound, it stays bound for the life of the action.
  6. Hold and mutateTake the 20:00 slot so it cannot evaporate mid-decision, then execute once under an idempotency key. If the response is lost to a timeout, the retry cannot book dinner twice.
  7. Read backAsk the provider what state it is actually in. If someone took the slot while this was in flight, the action fails closed and re-proposes rather than reporting success on the strength of an accepted request.
    fail closed · re-propose
  8. Coordinate and recordCalendar updated, guest notified, ride pushed back. If the notification fails after the reservation has moved, that is partial failure with real-world state already changed, and compensation decides what to retry, reverse, or escalate. One trace ties it together: what changed, what was proposed, which policy allowed it, what was revalidated, what each provider returned.

Now change one word. The restaurant cannot do 20:00, so the agent proposes reservation.cancel instead. Identical reasoning quality, identical confidence, completely different consequence — and this time policy stops at approval, because cancelling is destructive, socially visible, and something I would want to decide myself. Nothing about the model changed. The action class did.


The pattern is much bigger than a planner

StillOn is just where I happened to notice it. The same shape appears everywhere agents touch real systems: an engineering agent updating Jira, a sales agent modifying a CRM opportunity, an operations agent restarting infrastructure, a finance agent approving an expense, a support agent issuing a refund, a coding agent merging a pull request or triggering a deploy.

In every one of those, the model may reason perfectly well and still not be the thing that should decide. So the useful question is not can the agent use this tool. It is:

Under what authority can this agent take this action, for this user, in this state, under this policy — and with what evidence afterwards?

None of this is new. Only the caller is.

Nearly every piece of this already exists somewhere in our craft. Identity and authorization came from API design. Policy enforcement came from gateways. Retries and idempotency came from distributed systems. Observability came from running things in production. Compensation came from long-running workflows. Least privilege came from security engineering.

Agents did not invalidate any of it. They changed who is calling. The caller can now read natural language, reason over context, invent a plan nobody programmed, and compose tools in sequences nobody enumerated. That is genuinely powerful, and it is precisely why the boundary has to be explicit.

Giving an agent more tools does not make it more useful past a certain point. Increasingly, the more valuable architectural work is deciding what it should not be allowed to do on its own.

The same boundary, one layer up

The part that surprised me is that this pattern repeated in how the system got built, not just in how it runs.

I built StillOn with coding agents, and the failure mode there is the same one: an agent that reasons well and drifts anyway, because nothing structural was holding it to the design. The answer turned out to be identical in shape — write the architecture and business rules down as an explicit specification, define the interfaces before the implementation, generate the models and clients from OpenAPI and MCP schemas, and let the agent move fast inside those contracts.

The build-time loop Four stages left to right. A specification defines behavior, scope and rules. OpenAPI and MCP schemas define the contracts before any code. Codegen produces clients and models, and the coding agent fills in the logic behind them. Tests verify conformance so drift fails fast. A return edge runs from the tests back to the specification, labelled: failures return to the spec, not to the agent's judgement. BUILD TIME — THE SAME BOUNDARY, ONE LAYER UP contracts own the boundary 01 SPECIFICATION behavior, scope and rules, written down 02 OPENAPI / MCP SCHEMAS contracts defined before the code 03 CODEGEN + AGENT clients generated; agent fills in the logic behind them 04 TESTS / VALIDATION conformance checked drift fails fast failures return to the spec — not to the agent's judgement
The runtime loop constrains what the model may authorize. This one constrains what the coding agent may redefine. Same shape, different clock.

The contracts are the single source of truth, so implementation drift has somewhere to fail loudly instead of quietly accumulating. And because the boundary is the specification rather than the tool, none of it is tied to a particular coding agent.

At runtime, I give the model room to reason and constrain its authority. At build time, I give the coding agent room to implement and constrain the architecture, the specification, and the contracts. It is one idea applied twice: give the agent freedom inside the boundary, and never the authority to redefine the boundary itself.

Reasoning is delegated. Authority never is.