# How to let an AI agent perform irreversible actions safely

> A practical architecture for AI agents that spend money, deploy code, delete data, or change infrastructure without turning confirmation into a suggestion.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-06 | Updated: 2026-09-09 | Topics: [AI](https://flaviocopes.com/tags/ai/) | Canonical: https://flaviocopes.com/ai-agent-irreversible-actions-safely/

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](https://flaviocopes.com/i-built-an-mcp-server-to-buy-cloudflare-domains/). 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:

```ts
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:

1. The agent chooses the wrong domain.
2. The domain contains confusing Unicode characters.
3. The displayed price excludes a required multi-year term.
4. Availability changes after the first check.
5. The price changes before registration.
6. One quote is submitted twice.
7. Two concurrent calls use the same quote.
8. The request succeeds but the response is lost.
9. The agent retries and creates another unwanted action.
10. 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:

```text
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:

```text
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:

```mermaid
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:

```ts
function parseUsd(value: string) {
  const [dollars, cents = ''] = value.split('.')

  return Number(dollars) * 100 +
    Number(cents.padEnd(2, '0'))
}
```

The $20 limit becomes:

```ts
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:

```ts
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:

```text
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:

```text
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](https://blog.modelcontextprotocol.io/posts/2026-07-28/), 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:

```text
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:

```text
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:

```ts
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:

```ts
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:

```sql
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:

```text
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:

```ts
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:

```text
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:

```text
repository
commit SHA
build artifact digest
environment
test result
rollback target
```

For an email campaign:

```text
list ID
recipient count
subject
content digest
scheduled time
unsubscribe configuration
```

For deleting customer records:

```text
tenant ID
record IDs
record count
backup state
retention consequence
```

Different fields, same shape. The steps do not change:

1. Give the agent a narrow capability.
2. Separate discovery from execution.
3. Prepare one immutable decision record.
4. Enforce policy in code.
5. Recheck changing external state.
6. Show the exact consequences to a person.
7. Consume approval capability atomically.
8. Execute once.
9. Preserve ambiguous outcomes.
10. 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.
