
ENGINEERING
A Developer's Guide to Thinking in Agents, Not Apps
An app is safe to get wrong because a human reviews the output first. An agent removes that review by design. What to replace it with: MCP's tool annotations and their defaults, turn budgets, idempotency keys, and a reversibility table.
TL;DR
An app is safe to get wrong because a human reads the output before anything happens. An agent is not, because the output is the action. That single difference is the whole reason agent code that looks like app code keeps producing incidents nobody can quite explain.
Take a scheduling assistant that used to suggest three open slots for a person to pick from. Someone adds one line of scope: let it book the meeting once it finds a slot. The code barely changed. What changed is that a wrong answer used to be a UI bug, and now it's a calendar invite that already went out.
Teams read that as a reliability problem and reach for a bigger model. It is usually a design problem: the system was built assuming a human stood between the decision and the consequence, and nobody removed that assumption when they removed the human.
We think the fix isn't a framework. It's deciding, tool by tool and action by action, which ones still get a
human in front of them and which ones don't, before the agent ever runs rather than after it acts. The
protocols already encode that judgment. MCP's tool annotations default to destructiveHint: true, which is
the spec saying it assumes the worst about your tool until you tell it otherwise.
Overview
Take a scheduling assistant built the way most internal tools get built. A request arrives with an email address and a rough time window, a handler checks the calendar for conflicts, and it hands back times that work. A good, boring app. Then someone extends its scope by one sentence: once it finds a slot, book it. The code barely changes. The same logic that produced a suggestion now produces a sent invite.
The frustrating part is that the symptoms hide the cause. When an agent sends the wrong thing, calls the wrong tool, or loops on a task it should have abandoned, the instinct is to reach for a smarter model. The research does not support that instinct. A 2025 study that annotated more than 1,600 traces across seven multi-agent frameworks produced fourteen failure modes in three categories: system design issues, inter-agent misalignment, and task verification. None of the three is "the model was too small".
So it is a design problem, and most of the vocabulary for it already exists. MCP has a field for whether a tool destroys things. Agent SDKs have turn budgets. Payment APIs have idempotency keys. Below: the four places app-shaped thinking leaks, and what the specs already say to put there.
The question an app never has to answer is the one an agent can't avoid: who looks at this before it happens, and what happens if nobody does? Everything below is a different angle on that same question.
A Loop That Acts, Not a Function That Returns
An app's unit of work is a request and a response. Something calls it, it does a bounded amount of work, and it hands back a value that somebody or something else decides what to do with. An agent's unit of work is a loop: observe the state, decide what it means for the goal, take an action, fold the result back in, and decide whether to continue. The loop doesn't hand a decision back for review. It contains the decision.
while (!done && steps < maxSteps) {
const observation = perceive(state);
const thought = reason(goal, observation);
const action = decide(thought);
const result = act(action); // this line is where an app stops and an agent doesn't
state = update(state, result);
done = isGoalMet(state);
}The line worth staring at is act(action). In an app, the equivalent line returns a value to a caller. In an
agent, it runs in the real world: it sends the email, writes the row, calls the refund API. Everything
upstream of that line can be exactly as careful as the best app code you've ever written and it still won't
matter if nothing checks what happens the moment act executes.
Two things about that loop deserve pinning down, because frameworks disagree about them. The first is how the loop knows it is finished. OpenAI's Agents SDK states the rule plainly: output counts as final when "it produces text output with the desired type, and there are no tool calls." Read that as a warning. A model that stops calling tools has ended your loop, done or not.
The second is measurement. An app's correctness gets sampled once per output, by the human reading it. An agent's is whatever it does across repeated unsupervised runs, which is a much worse number. The τ-bench authors built a metric for it, pass^k, scoring success across all k attempts rather than the best of them. Their reported finding was that function-calling agents "succeed on <50% of the tasks" and post "pass^8 <25% in retail". Right slightly more often than not on one try is close to useless eight tries running, and eight tries running is what production is.
Tools Are Choices the Agent Makes, Not Calls You Schedule
In an app, a function call happens at a place in the code you chose. In an agent, a tool is a capability the model decides whether to reach for. MCP says this directly: tools are "model-controlled", meaning the model "can discover and invoke tools automatically based on its contextual understanding and the user's prompts." The description is not documentation sitting next to the code. It is part of the runtime.
// Vague: the agent has to guess when this applies, and it will guess wrong
{ name: "get_data", description: "Gets data." }
// Precise: the agent knows the precondition, the output, and the failure behavior
{
name: "lookup_customer_by_email",
description: "Returns a customer's account record given their email. Use when you need " +
"order history, plan tier, or billing status. Returns null if no account matches; do not retry on null.",
}The second version is doing work that, in an app, would have lived in an if statement a developer wrote and
reviewed once. Here it lives in a sentence the model reads fresh on every decision. Naming a tool for exactly
what it does, stating what it returns on failure, and saying explicitly what not to do with that failure is
the agent equivalent of writing the branch you used to write in code.
There is a second layer most people skip. MCP tools carry an optional annotations object whose fields are
about consequence rather than behaviour, and the defaults in the schema are the interesting part.
| Annotation | Default in the schema | What the default is telling you |
|---|---|---|
readOnlyHint | false | Assume the tool modifies its environment until someone says it doesn't |
destructiveHint | true | An unannotated write is assumed destructive, not additive |
idempotentHint | false | Assume calling twice with the same arguments does the thing twice |
openWorldHint | true | Assume the tool reaches an open world of external entities |
Table 1 — The four MCP tool annotations and their schema defaults. Three of the four default to the pessimistic reading.
Fill those in for your own tools and you have the beginnings of a gate: read-only calls need no ceremony,
non-idempotent destructive ones need a person. Do not fill them in for someone else's tools and then believe
them. The spec is blunt: clients "MUST consider tool annotations to be untrusted unless they come from trusted
servers." Annotations record what you know about your own tools. Treating a third-party server's
readOnlyHint: true as a boundary is how a tool that claims to read ends up writing.
Sorting Actions by Whether an Undo Exists
Risk tiers are the usual way teams sort actions, and they stall because everyone rates risk differently. A better axis is mechanical: does an undo exist, who performs it, and does anyone see the effect first. That question has an answer in the API reference rather than in a workshop.
| Action | Undo path | Who sees it first | Gate we would build |
|---|---|---|---|
| Read a calendar's free/busy | None needed | Nobody | None |
| Create a draft message | Delete the draft | The drafting user | None |
events.insert with sendUpdates: "all" | Delete the event; the notification has already gone | Every guest | Confirm before the call |
events.insert with sendUpdates: "none" | Delete the event | Possibly nobody | Confirm, and read the warning below |
| Send a message | Nothing that unsends it | The recipient | Confirm, always |
| Charge a card | Refund, which is a second real event | The cardholder and your ledger | Confirm plus an idempotency key |
| Delete a record | None | Whoever needed it | Human performs it, not the agent |
Table 2 — The same set of tools sorted by undo path rather than by a risk label.
The Google Calendar row is a good illustration of why reading the parameter docs beats reasoning from first
principles. Suppressing notifications looks like the safe default for an agent, and it is not: Google warns
that using none "can have significant adverse effects, including events not syncing to external calendars
or events being lost altogether for some users." The quiet option is not the harmless option. It is a
different failure, moved somewhere you won't see it.
Reversibility is a property of the API, not of your intentions. Before you let an agent call something, find the undo in the provider's reference. If there isn't one, that call is a confirmation step wearing a tool's clothes.
An Exit, Not Just a Path
Two lines in the loop above do more work than the rest combined, and both are things an app never needs.
maxSteps is what stops a confused agent from spinning forever on a task it can't complete. isGoalMet is
what lets it admit it failed instead of returning something confident and wrong so the loop can end. An agent
that can't stop and an agent that can't say it didn't finish are the same failure wearing different clothes.
Frameworks give you the first one. OpenAI's Agents SDK raises MaxTurnsExceeded when a run exceeds the
max_turns you passed, and documents that "Pass max_turns=None to disable this turn limit." That sentence
is the footgun. A disabled budget is not a generous budget. It is an agent with no arithmetic between it and
your bill.
Retries are the other half, and this is where app habits do real damage. An app's retry replays an identical request and hopes the network cooperates. An agent's retry usually should not be identical: the tool returned nothing, so it broadens the query; the output didn't parse, so it reformats. But when the retried call is a write, "identical" is exactly what you want, and the mechanism for it is an idempotency key.
Stripe's implementation is the one worth copying because the semantics are written down. It "works by saving
the resulting status code and body of the first request made for any given idempotency key, regardless of
whether it succeeds or fails", so a repeat with the same key returns the same answer, "including 500
errors". Keys are removable "after they're at least 24 hours old", and reusing a key with different
parameters is an error by design: the layer "compares incoming parameters to those of the original request
and errors if they're not the same to prevent accidental misuse."
That has a direct consequence for how you generate the key in an agent. Derive it from the intent, not from the attempt.
def idempotency_key(run_id: str, step: int, intent: dict) -> str:
# One key per (run, step, intended effect). A retry of the same step reuses it;
# a genuinely new charge produces a new one. Never key on time or on attempt number.
payload = json.dumps(intent, sort_keys=True).encode()
return f"{run_id}:{step}:{hashlib.sha256(payload).hexdigest()[:16]}"
# Retrying the same step sends the same key, so the provider replays its first
# response instead of charging twice. Changing the amount changes the key, which
# is correct: that is a different action, not a retry.Two rules fall out. A write the agent may retry needs a key of some kind, and if the provider offers none, the agent should not be the thing retrying it. And keys expire, so a job resumed a day later is a new action wearing an old one's clothes.
What Acting on Its Own Actually Costs You
In an app, a mistake is a bad output waiting for someone to notice it before it matters. In an agent, a mistake is already an action, and the review is a postmortem rather than a gate.
Concretely: the scheduling assistant that now books meetings has to answer a question the suggestion-only version never faced. What if the slot it picked is technically free but obviously wrong, a 2 a.m. call across nine time zones that a scheduling API has no way to flag? An app returning three options lets a human catch that instantly. The fix is not a smarter model that never picks a bad slot. It's deciding, before the agent runs, that booking is an action a human confirms and suggesting is not.
The protocols agree, and they are unusually direct about it. MCP's tools specification carries a standing
warning that "there SHOULD always be a human in the loop with the ability to deny tool invocations", and its
security guidance tells clients to "Show tool inputs to the user before calling the server, to avoid
malicious or accidental data exfiltration." OWASP's Top 10 for Agentic Applications puts the same rule in the
mitigation list for identity and privilege abuse: "Require human approval for high-privilege or irreversible
actions." That is aact:human-oversight/approval-for-high-risk in the
Agent Access Control Taxonomy, where only
actions matching a risk rule wait for a named human.
Figure 1 — The gate, drawn as the runtime's job rather than the model's. The model never sees a credential and never decides whether it needed permission.
The three-state response is the part app code never models. MCP's elicitation flow defines accept,
decline and cancel, where decline is an explicit refusal and cancel is the user dismissing the dialog
without choosing. An agent that reads a dismissal as a no will stall; one that reads it as a yes is the
incident. Elicitation also fences off what a confirmation may ask for: servers "MUST NOT use form mode
elicitation to request sensitive information such as passwords, API keys, access tokens, or payment
credentials", and must use out-of-band URL mode instead.
None of this requires a bespoke framework. n8n ships a human review step for tool calls where "The workflow
pauses and sends an approval request through your configured channel", showing the tool name and parameters,
and "If denied, the action is canceled." The mechanism is ordinary, the taxonomy's
aact:enforcement-point/in-agent-code. Deciding which tools sit behind it is the work.
human finds out The review point moves from before the consequence to after it, unless you deliberately put it back. -->
Figure 2 — Where the human review point sits in each design. An app puts it before the consequence by default. An agent puts it after, unless the boundary is built in on purpose.
Governing an agent means deciding, in advance, which actions get a human in front of them and which don't. Not because the model can't be trusted, but because "acted, and we found out later" is the wrong shape of feedback loop for anything you can't undo.
Failure Modes You Can Name
The symptoms of app-shaped agent code look alike from the outside. Here is how we would tell them apart.
| Symptom | What it usually is | The tell |
|---|---|---|
| Run never ends, spend climbs | No turn budget, or one disabled | No MaxTurnsExceeded in the logs and no step counter in the trace |
| The same message went out twice | A retried write with no idempotency key | Two provider-side records with different ids, seconds apart |
| Agent reports success, nothing changed | The loop exited on text with no tool calls | Final assistant message present, zero write calls in the trace |
| Wrong tool for the situation | An ambiguous description, not a weak model | Re-read the description cold. If you can't tell when it applies, neither could the model |
| A "read-only" tool wrote something | A third-party annotation trusted as a boundary | The tool came from a server you don't control |
| Users approve everything without reading | Every call gated because nothing was classified | Approval rate near 100% and median approval time under two seconds |
Table 3 — Six failures that all present as "the agent did something strange", and what separates them.
The third row is the one people argue about, and the failure taxonomy above is why we would not argue: "task verification" is one of its three categories. The agent does something defensible and nothing checks the result against the goal. Verification is a step you write.
Where Fabriq Fits
Agentic Fabriq sits at the point where the loop reaches a real API. Credentials stay in a vault and get attached server-side at the moment of the call, so the model never handles a token. Every call carries two identities, the agent and the acting user, and lands in an audit record, and an agent's effective tool list is the intersection of the user's scopes and the agent's own.
What that gives you is a smaller blast radius and a trail, not a confirmation step. Fabriq has no approval workflow: the gate in Figure 1 is yours to build, wherever your runtime sits. It also cannot revoke a grant at the provider. Clearing a vault copy of a credential removes our access to it and leaves the upstream grant exactly where it was, so a real revocation is still a call you make to Google, Microsoft or Slack yourself.
Frequently Asked Questions
What is the difference between an AI agent and an app? An app returns output that a human acts on. An agent acts, and the human finds out afterwards unless you put a gate back in. Everything else, the model, the framework, the prompt, is implementation detail next to that.
How many steps should an agent be allowed to take? Pick a number you can defend for the task and enforce
it. OpenAI's Agents SDK raises MaxTurnsExceeded at the max_turns you set and lets you pass
max_turns=None to remove the limit, which we would treat as a production incident waiting to be filed.
How do I stop an agent doing the same thing twice? Give every write an idempotency key derived from the intended effect, so a retry replays the provider's first response rather than performing a second action. Stripe's keys behave this way, and its layer errors if you reuse a key with different parameters. Note that keys are pruned after roughly 24 hours, so a resumed job is a new action rather than a retry.
Which actions need human approval? The ones with no undo, and the ones whose side effect is a notification somebody else receives. MCP's guidance is that there "SHOULD always be a human in the loop with the ability to deny tool invocations", and OWASP's agentic Top 10 asks for human approval on "high-privilege or irreversible actions". Reads and drafts do not need it, and gating them is how you train people to click through.
Can I trust a tool's readOnlyHint? Only for tools you wrote. MCP requires clients to treat annotations
as untrusted unless they come from a trusted server, so a hint from a third-party MCP server is a claim, not a
guarantee.
Why does my agent say it finished when it didn't? Because "finished" is usually defined as text output with no tool calls, which a model can produce whenever it stops calling tools. Add an explicit verification step that checks the goal against observed state, and let the agent report failure as a first-class outcome.
Conclusion
None of this argues against building agents, or for keeping a human in every loop until the idea stops paying for itself. It argues for noticing where the review that made app-shaped code safe went missing, and putting a deliberate boundary in its place: a turn budget, an honest failure signal, annotations precise enough that the runtime can classify a call before it happens, an idempotency key on anything retryable, and a tool-by-tool line between actions that run alone and actions a person sees first.
We'd start that line with reversibility, the one criterion you can look up rather than argue about. If an action can be cleanly undone, let the agent take it. If it can't, the agent proposes and a person decides, until you have enough evidence about that specific action to decide otherwise.
The spec authors already made this call for you. MCP's annotation defaults assume an unannotated tool is destructive, non-idempotent and open-world. Start from the same assumption about every tool you hand an agent, and make each exception on purpose.
Sources
All URLs read 2026-09-29.
- MCP specification: Tools (2026-07-28) — tools are "model-controlled", the human-in-the-loop warning, annotations as untrusted, and the client security checklist.
- MCP schema.ts, 2026-07-28 — the
ToolAnnotationsinterface and the documented defaults forreadOnlyHint,destructiveHint,idempotentHintandopenWorldHint. - MCP specification: Elicitation (2026-07-28) — the accept/decline/cancel action model and the prohibition on requesting credentials in form mode.
- MCP specification overview — the consent and tool-safety principles, and the current revision's stateless core.
- OpenAI Agents SDK: Running agents — the agent loop, the final-output rule,
max_turns,MaxTurnsExceededandmax_turns=None. - Stripe API: Idempotent requests — replayed status and body, the 24-hour pruning window, and the parameter-mismatch error.
- Google Calendar API: Events.insert —
sendUpdatesvalues and the warning about the adverse effects ofnone. - τ-bench: A Benchmark for Tool-Agent-User Interaction in Real-World Domains — the pass^k reliability metric and the reported "<50% of the tasks" and "pass^8 <25% in retail".
- Why Do Multi-Agent LLM Systems Fail? — 14 failure modes in three categories, from 1,600+ annotated traces across seven frameworks.
- OWASP Top 10 for Agentic Applications 2026 — published December 2025; ASI03's mitigation list is where "human approval for high-privilege or irreversible actions" comes from. Text read from the project's own source repository at GenAI-Security-Project/translations.
- n8n: Human-in-the-loop for tools — a shipped implementation of tool-level approval, including what happens on denial.
- Agent Access Control Taxonomy — the human oversight and enforcement point dimensions, and the questions they ask: when a person gets involved in an agent's actions, and where the decision is enforced.
- Agent access control taxonomy, human-oversight.yaml —
aact:human-oversight/approval-for-high-riskas actions matching a risk rule waiting for a named human, againstnone,post-hoc-reviewandapproval-for-all.