Rowboat realism marine painting
← Back to blog

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.

•Apr 13, 2026•Updated Oct 13, 2026•15 min
EngineeringMental ModelsAgents

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.

AnnotationDefault in the schemaWhat the default is telling you
readOnlyHintfalseAssume the tool modifies its environment until someone says it doesn't
destructiveHinttrueAn unannotated write is assumed destructive, not additive
idempotentHintfalseAssume calling twice with the same arguments does the thing twice
openWorldHinttrueAssume 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.

ActionUndo pathWho sees it firstGate we would build
Read a calendar's free/busyNone neededNobodyNone
Create a draft messageDelete the draftThe drafting userNone
events.insert with sendUpdates: "all"Delete the event; the notification has already goneEvery guestConfirm before the call
events.insert with sendUpdates: "none"Delete the eventPossibly nobodyConfirm, and read the warning below
Send a messageNothing that unsends itThe recipientConfirm, always
Charge a cardRefund, which is a second real eventThe cardholder and your ledgerConfirm plus an idempotency key
Delete a recordNoneWhoever needed itHuman 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.

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

Provider APIHumanRuntimeModelProvider APIHumanRuntimeModeldecline and cancel are different statestool call (send_invite, args)classify: destructive, not idempotentshow tool name + argumentsaccept / decline / cancelcall, with idempotency key (only on accept)resultresult, or a refusal the model must handle

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.

agent

too late

goal

loop:
observe / reason / decide

act()

real world changes
(sent, written, executed)

human finds out

app

request

handler

output

human reviews,
then acts

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.

SymptomWhat it usually isThe tell
Run never ends, spend climbsNo turn budget, or one disabledNo MaxTurnsExceeded in the logs and no step counter in the trace
The same message went out twiceA retried write with no idempotency keyTwo provider-side records with different ids, seconds apart
Agent reports success, nothing changedThe loop exited on text with no tool callsFinal assistant message present, zero write calls in the trace
Wrong tool for the situationAn ambiguous description, not a weak modelRe-read the description cold. If you can't tell when it applies, neither could the model
A "read-only" tool wrote somethingA third-party annotation trusted as a boundaryThe tool came from a server you don't control
Users approve everything without readingEvery call gated because nothing was classifiedApproval 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.