# Human in the loop for AI agents: irreversible actions

> How I built human in the loop confirmation into an AI agent with tool calling: irreversible actions become a card, the user clicks, the result goes back.

2026-10-15 · Gustavo Karsten

When an AI agent only reads data, a mistake costs you one follow-up question. When an AI agent emails a customer, spends money or deletes a record, a mistake is permanent. Here is how I built human in the loop confirmation into the agent behind [LedgerJob](https://gustavokarsten.com/en/projects/ledgerjob), with excerpts from the real code.

LedgerJob is a SaaS for US construction contractors. Owners run leads, quotes, projects and invoices by chatting with an agent. The agent has 66 tools, and 16 of them require human confirmation before they run.

## What goes wrong without human in the loop

With AI agent tool calling, the model picks a tool based on what it understood from the conversation. The problems show up at the edges:

- The user says "send the Smith quote" and there are two clients named Smith. The model picks one.
- The user says "go ahead and delete that lead" three messages after talking about a different lead.
- The model says "email sent" when the tool failed, or was never called.

In a search, that costs one extra question. In an email with a PDF attached going to the wrong customer, there is no undo. The pattern is simple: the agent can propose anything, but anything irreversible only runs after a person clicks.

## Step 1: classify your tools

Every tool has to declare what kind of tool it is. In LedgerJob that lives in the tool definition:

```ts
export interface AssistantTool {
  name: string;
  // ...description, parameters, label
  /** Read-only. Becomes `readOnlyHint` over MCP. */
  readOnly?: boolean;
  /** Irreversible/external action: does NOT run in the loop, becomes a confirm card. */
  requiresConfirmation?: boolean;
  /** Human-readable text for the confirm card (may query the database). */
  confirmSummary?: (ctx: AssistantToolContext, args: any) => Promise<string>;
  execute: (ctx: AssistantToolContext, args: any) => Promise<unknown>;
}
```

Tools fall into three groups. Reads (search clients, list overdue invoices) run immediately. Reversible writes (create a draft quote, move a lead on the kanban) also run immediately, since you can fix them later. Then the irreversible ones: emailing a quote or invoice, sending a text, deleting a lead, client, quote or invoice, converting a lead into a client, ordering a paid aerial measurement from EagleView, canceling a service agreement. Those 16 carry `requiresConfirmation: true`.

The rule: does it leave the company (email, SMS), spend money with a third party, or change data permanently? If any answer is yes, it needs confirmation.

## Step 2: the pending action

Inside the agent loop, when the model calls a flagged tool, the tool does not run. The backend stores a pending action, pushes an event to the UI, and hands the model a result that makes it clear nothing happened:

```ts
} else if (tool.requiresConfirmation) {
  // Gate: don't execute. Create a pending action + UI card; tell the model.
  const args = call.arguments ? JSON.parse(call.arguments) : {};
  const summary = tool.confirmSummary
    ? await tool.confirmSummary(ctx, args)
    : tool.label;
  const action = await assistantService.createPendingAction(
    opts.conversationId, opts.tenantId, opts.userId, call.name, args, summary
  );
  opts.send('confirm', { actionId: action.id, name: call.name, label: tool.label, summary });
  output = JSON.stringify({
    status: 'pending_user_confirmation',
    note: 'The action was proposed to the user as a confirmation card. It has NOT been executed. Tell the user to confirm it. Do not claim it was done.',
  });
}
```

Two details matter. `confirmSummary` queries the database to build text a person can check at a glance: quote number, title, total, recipient name and email, instead of a UUID. If the email address is wrong, the user sees it before clicking. And the result returned to the model is explicit: "NOT been executed". The system prompt repeats the rule: when a tool returns `pending_user_confirmation`, ask the user to confirm the card and never claim the action was done.

## Step 3: confirmation in the UI

The UI receives the `confirm` event over the same SSE stream as the text response and renders a card with the summary and two buttons. The click hits a separate endpoint that only accepts `confirm` or `reject`.

The lookup filters by tenant, user, conversation and `status: 'pending'`. Nobody can confirm another user's action. A double click can't run the action twice, because after the first one the status is no longer `pending`. And there is an expiry rule: when the user sends a new message, every pending proposal in that conversation becomes `expired`.

## Step 4: feed the outcome back into the history

The click happens outside the model's loop. If the outcome never makes it back into the conversation history, on the next message the agent has no idea whether the email went out, failed or was rejected. So it guesses.

In LedgerJob, all three outcomes become a message in the history:

```ts
if (decision === 'reject') {
  await assistantService.markPendingAction(actionId, 'rejected');
  await assistantService.appendMessage(conversationId, ctx.tenantId, 'system', [
    { role: 'developer', content: `The user REJECTED the proposed action "${action.toolName}" (${action.summary}). It was not executed.` },
  ]);
  return { status: 'rejected' };
}
```

On confirm, the tool runs and its result goes into the history (capped at 2,000 characters). If it fails after the click, the message says why.

## Step 5: guard business rules in the service layer

Human confirmation protects you from the agent doing the wrong thing. It does not protect you from the agent doing the right thing with bad data. For that, validation has to live where every path goes through.

A real bug made this clear. The web form checked that an appointment ends after it starts, but that check lived in the HTTP route's DTO. The agent and the MCP server call the service directly, so the assistant could book a 6 pm to 5 pm appointment. Google Calendar rejects that event, so the appointment was saved in LedgerJob and never reached the crew's calendar. The fix moved the rule into the service, with a test:

```ts
/**
 * Lives in the service because the DTO schemas only cover the HTTP route:
 * the MCP server (assistant) calls this service directly and bypassed it.
 */
function assertEndAfterStart(start: Date | string, end: Date | string) {
  if (new Date(end) <= new Date(start)) {
    throw new Error('endDatetime must be after startDatetime');
  }
}
```

The takeaway: if an AI agent can reach a function, the validation lives inside the function, not at the HTTP door.

## Step 6: limits on the loop

Three more limits round out the AI agent orchestration:

- **8 iterations per turn, max.** If the model gets stuck calling tools in a cycle, the turn ends.
- **Tool output truncated at 6,000 characters**, with a note asking for a narrower filter. A large listing can't blow up the context or the database row where the history is stored.
- **Identity comes from the session or API key, never from the model.** Tenant, user and role are injected by the backend. The model has no way to pass a different `tenantId` as an argument.

## What about external agents over MCP?

The same 66 tools are exposed through an MCP server for Claude Code, Codex or any other client. There is no LedgerJob card there. Approval belongs to the MCP client, which has its own permission prompt. My job is to give it the right signals:

```ts
description: t.requiresConfirmation
  ? `IRREVERSIBLE: sends a message, spends money or permanently changes data. Ask the user before calling. ${t.description}`
  : t.description,
annotations: {
  readOnlyHint: Boolean(t.readOnly),
  destructiveHint: Boolean(t.requiresConfirmation),
},
```

The `IRREVERSIBLE` prefix is for the model. The `readOnlyHint` and `destructiveHint` annotations are for the client, which uses them to decide what can run without asking. One `requiresConfirmation` flag drives both surfaces, so a new tool ships already classified in the chat and over MCP.

## The pattern in short

1. Classify every tool: read, reversible write, irreversible.
2. Irreversible tools become a pending action; the model is told "not executed".
3. A card with human-readable text; the endpoint checks owner, conversation and status.
4. The outcome goes back into the history.
5. Business rules live in the service layer.
6. Cap iterations and output size; identity never comes from the model.

On the home page of this site, the LedgerJob card runs a looping demo of this flow: the request, the card waiting, the click and the executed action. More on the [LedgerJob page](https://gustavokarsten.com/en/projects/ledgerjob).

If you're putting AI agents in charge of real business data and want to compare notes, [get in touch](https://gustavokarsten.com/en/contact).

---
Page: https://gustavokarsten.com/en/articles/confirmacao-humana-agentes-ia
Contact: gustavo.karsten@outlook.com · https://www.linkedin.com/in/gustavo-karsten-88a04383/ · https://github.com/gustavo-karsten
