How to let an AI agent perform irreversible actions safely
By Flavio Copes
A practical architecture for AI agents that spend money, deploy code, delete data, or change infrastructure without turning confirmation into a suggestion.
I used an AI agent to buy hostingpicker.dev through the Cloudflare Registrar API. It cost $12.20, the registration went through immediately, and there is no refund on a domain purchase.
The API call itself was a few lines. What took real work was making sure the agent could not buy the wrong domain, approve its own purchase, reuse an old price, or retry a request that might have already gone through.
Domains are just one example. We are giving agents tools that can:
- spend money
- deploy code
- delete data
- change DNS
- send email to thousands of people
- create infrastructure
- publish content
You cannot protect any of those with “always ask me first” in the system prompt. The model may follow it, or it may not. A prompt is advice. A safety boundary has to be code the model cannot get around.
I built that boundary for my Cloudflare Domains Toolkit. In this post I want to take it apart and show the parts you can reuse for any irreversible action.
The rule
I started with one sentence:
The agent can search and prepare. A person approves the exact live action.
Everything else in the design comes from it.
The agent can suggest domains, check whether they are available, work out the price, and prepare a quote. None of that gives it permission to buy. The purchase needs a separate approval from me, tied to one exact domain and one exact amount.
Confirmation is not a boundary
This tool is unsafe:
async function buyDomain(domain: string) {
const confirmed = await agentAsksUser(
`Buy ${domain}?`
)
if (confirmed) {
await cloudflare.register(domain)
}
}
The problem is that the agent writes the question. It can leave out the price. It can ask before it has checked the final state. It can read “hmm, I guess” as a yes.
And buyDomain() still takes any string, so nothing ties the answer to the domain that gets registered. A second call with a different domain works just as well.
What you have is a chat message, not an approval.
Start with the threat model
Before writing the tools, list how the action can go wrong.
For a domain purchase:
- The agent chooses the wrong domain.
- The domain contains confusing Unicode characters.
- The displayed price excludes a required multi-year term.
- Availability changes after the first check.
- The price changes before registration.
- One quote is submitted twice.
- Two concurrent calls use the same quote.
- The request succeeds but the response is lost.
- The agent retries and creates another unwanted action.
- A broad API token lets the tool change unrelated account state.
Some of these are the model getting it wrong. Others, like a lost response or two concurrent calls, would happen with no AI involved at all. The boundary has to cover both kinds.
Give the agent narrow capabilities
My Registrar MCP server exposes four tools:
search_domains
check_domain
quote_domain_purchase
purchase_domain
DNS management is a separate command-line tool with its own API token. The registration token cannot touch DNS, and the DNS token cannot buy anything.
That is least privilege, but it also makes the code simpler. The purchasing server does one thing, with one credential, and has exactly one dangerous tool to protect.
What I avoided is a generic tool like:
cloudflare_api_request(method, path, body)
It looks convenient. In practice its capability is “anything this token can do”, and you cannot reason about what the agent might do with it. Give the agent tools named after what you want it to accomplish, not a raw HTTP client.
Separate discovery, preparation, and execution
The four tools form three phases:
flowchart LR
D["Discovery"] --> P["Preparation"]
P --> A["Human approval"]
A --> E["Execution"]
E --> V["Verification"]
Discovery is loose. The agent searches, tries ideas, checks what is available. Nothing here can cost money.
Preparation is where it gets strict. One exact domain gets validated, priced, and written down as a quote.
Execution takes that quote, checks the live state again, asks a person, and does one thing.
The same split works elsewhere. A deploy tool can inspect the changes, prepare one immutable release, and promote only that release. A deletion tool can search records, prepare the exact IDs and what deleting them means, and then delete only that set.
The server owns policy
The model never gets to decide whether an action is allowed. The server does, in code.
For the domain tool, the server enforces:
- plain ASCII domain names
- one exact registrable domain
- prices in USD
- no premium domains
- a maximum initial charge of $20
- complete minimum registration term pricing
- auto-renewal disabled
- a five-minute quote lifetime
The agent can explain these rules to me. It cannot change them, and it cannot talk the server out of them.
Same idea for a deploy tool. If production requires a green test suite, the tool checks the test result itself. It does not ask the model whether the tests look fine.
The model is good at judgment calls, like which domain name reads better. Rules that must always hold belong in plain code.
Represent money without floating point
Cloudflare returns decimal price strings.
The server converts them to integer cents:
function parseUsd(value: string) {
const [dollars, cents = ''] = value.split('.')
return Number(dollars) * 100 +
Number(cents.padEnd(2, '0'))
}
The $20 limit becomes:
if (initialChargeCents > 2000) {
throw new Error('Purchase exceeds the safety limit')
}
This avoids floating-point comparisons around a billable action.
The server also calculates the complete initial charge. Some domain extensions require more than one year, so the first payment can include a registration year plus required renewal years.
The value presented for approval must be the value the provider will charge.
A quote is a temporary decision record
The preparation phase creates a quote:
type PurchaseQuote = {
id: string
domain: string
currency: 'USD'
registrationCents: number
renewalCents: number
years: number
totalCents: number
autoRenew: false
expiresAt: number
status: 'available' | 'consumed'
}
This is more than a cache of API responses. It is the exact thing a person will be asked to approve, with every value that matters written down.
The purchase tool does not take a price or a number of years as input. If it did, the caller could pass anything. Instead the caller passes the quote ID and repeats the public values, and the server checks them against its own copy. Any difference and the purchase stops.
Quotes expire after five minutes and live in memory, so a restart wipes them. That is fine. A quote does not reserve anything. It records one decision for a short time.
Recheck external state before approval
Availability and prices can change after quote creation.
The execution phase asks Cloudflare again:
check availability
check registration price
check renewal price
check minimum term
recalculate complete initial charge
If any approved value changed, the server rejects the quote.
The agent must prepare a new one.
This closes a time-of-check/time-of-use gap.
The person should not approve an old $12.20 quote while the action is about to spend $120.20.
The order matters:
prepare quote
wait
recheck live state
show approval
execute immediately
Do not request approval first and refresh the values afterward.
Put approval at the protocol boundary
The final decision uses an MCP form-mode elicitation.
In the 2026-07-28 MCP protocol, the tool returns an input_required result containing the elicitation. The client displays the form, collects the answer, and retries the original tool call with the response attached.
The wire format changed from the earlier version of the protocol, where the server sent a request to the client. What did not change is who draws the form: the MCP client, not the model.
The server asks the MCP client to display a structured form containing:
- exact domain
- exact total charge
- registration term
- non-refundable warning
- auto-renewal state
- an unchecked approval box
The elicitation response distinguishes three actions:
accept
decline
cancel
The retried call continues only when the answer is accept and the approval box is true. The server checks the returned values itself rather than assuming the form schema did it.
If the client cannot show the form, the purchase tool fails. There is no environment variable or “trusted mode” to skip the approval. A client that cannot ask the question does not get to buy domains.
Approval content is part of the security design
A form that says “Continue?” is useless. I should be able to make the decision from the form alone, without scrolling back through the chat to work out what the agent is about to do.
The form has to answer:
What will happen?
Which exact resource is affected?
How much will it cost?
Can it be undone?
What happens later?
For the domain purchase, “what happens later” includes auto-renewal.
For a deployment, it includes the environment and release identifier.
For deletion, it includes the number and type of records.
Consume single-use state before waiting
My first implementation marked the quote as used after approval.
The sequence looked like this:
const approved = await requestApproval(quote)
quote.status = 'consumed'
This contains a race.
Two purchase calls can reach requestApproval() before either call consumes the quote. The client may display two forms for one quote.
The fix is small:
quote.status = 'consumed'
const approved = await requestApproval(quote)
Mark the quote used before you await anything.
If the person then declines, the quote stays used and the agent has to prepare a new one. That costs nothing. A single-use quote that can be used twice costs real money.
In a multi-process server, an in-memory assignment is not enough. Use an atomic database update:
UPDATE purchase_quotes
SET status = 'consumed'
WHERE id = ?
AND status = 'available';
Continue only when one row changed.
An ambiguous result is not a failed result
Imagine the registration request reaches Cloudflare.
Cloudflare buys the domain, but the connection drops before the response returns.
The client sees an error. Did the purchase fail?
We do not know.
Automatically retrying could repeat a billable or destructive action. The server therefore leaves the quote consumed and reports an ambiguous outcome.
The person must inspect the authoritative account state before trying again.
This distinction applies to many systems:
explicit rejection → safe to report failure
confirmed success → safe to report success
connection lost after sending → outcome unknown
If the external API supports idempotency keys, use one stable key for the logical action. Retrying with the same key can then return the original result instead of performing the action again.
Without idempotency keys, a retry loop only hides the fact that you do not know what happened.
Verify the result separately
A 200 from the API is a good sign, but the account is the source of truth.
After execution, record:
- quote ID
- exact requested action
- provider request or workflow ID
- approval time
- execution time
- response state
- verification state
Then query the provider when possible.
For a domain, verify that the domain appears in the account.
For a deployment, verify the promoted release and health checks.
For a deletion, verify that the records no longer exist.
Do this as a separate step, after the call returns. Do not fold it into the success response.
Test the boundary without the real capability
The policy tests should not use production credentials.
Put the provider behind a small interface:
interface RegistrarClient {
checkDomain(domain: string): Promise<DomainState>
createRegistration(
domain: string,
years: number
): Promise<RegistrationResult>
}
Tests use a fake implementation.
The fake can simulate:
- a price change
- a domain becoming unavailable
- a required multi-year term
- a timeout after receiving the request
- two concurrent purchase calls
- a duplicated quote
Write a test for each of those.
For my tool, the live purchase came last, after the policy and protocol tests passed. That one run was the only way to see the real MCP client show the approval form at the right moment.
A reusable state machine
The complete action can be represented as a state machine:
discovered
↓
prepared
↓
rechecking
↓
awaiting_approval
↓
executing
↓
confirmed | rejected | outcome_unknown
Do not collapse outcome_unknown into rejected.
Do not return from awaiting_approval to the same prepared capability. Create a fresh decision record.
The model never sets a state. The server moves the action forward, one valid transition at a time.
Applying the pattern elsewhere
For a production deployment, the decision record might contain:
repository
commit SHA
build artifact digest
environment
test result
rollback target
For an email campaign:
list ID
recipient count
subject
content digest
scheduled time
unsubscribe configuration
For deleting customer records:
tenant ID
record IDs
record count
backup state
retention consequence
Different fields, same shape. The steps do not change:
- Give the agent a narrow capability.
- Separate discovery from execution.
- Prepare one immutable decision record.
- Enforce policy in code.
- Recheck changing external state.
- Show the exact consequences to a person.
- Consume approval capability atomically.
- Execute once.
- Preserve ambiguous outcomes.
- Verify and audit the result.
What approval does not solve
Human approval is not a complete security system.
People can approve the wrong thing. Repeated prompts create confirmation fatigue. A compromised MCP client can render misleading information. A compromised server can ignore its own policy. Stolen provider credentials bypass the agent workflow entirely.
Use additional boundaries:
- narrowly scoped credentials
- hard spending and resource limits
- provider-side roles
- separate production accounts
- audit logs
- alerts for sensitive actions
- credential storage outside prompts and source code
Approval is one layer. Its job is narrow: connect one human decision to one prepared action.
What I took away from this
Knowing how to call an API is not a reason to let the agent call it. The model is good at exploring options, explaining trade-offs, and preparing the work. Code decides what is allowed. A person answers the one question left at the end.
Set up that way, you get useful agent tools without having to trust the model as an operator. The Cloudflare API call was the easy part of this project. The boundary around it was the product.
Want me to talk about your product? You can sponsor this site.
Related posts about ai: