Ruby and Rails glossary

Human in the loop for AI agents

Human in the loop is a pattern where an AI agent proposes an action and a person approves or rejects it before it runs. The agent does the work of finding and preparing the change; a named person signs off on it.

Human in the loop is a pattern where an AI agent proposes an action and a person approves or rejects it before it runs. The agent finds the record, works out the change and prepares it. A named person looks at what it prepared and says yes or no. Nothing the person has not approved reaches your data, your payment provider or your customers.

The pattern is not a judgment on agents. It is a decision about which steps in a workflow you want a person to sign off, the same decision you already make when a new support hire can issue refunds under a limit but needs a lead above it.

Human in the loop vs human on the loop

The two names describe where the person sits.

  • Human in the loop: the action waits. The agent creates a proposal, and it does not run until someone approves it. If nobody approves, nothing happens.
  • Human on the loop: the action runs straight away, and a person watches. They see what the agent did, can stop it, and can undo what is undoable. Review happens after the fact.

Most real setups use both. Reads and small, reversible writes run with a person on the loop. Anything costly, permanent or visible to customers waits for a person in the loop. The question for each action is simple: if this went wrong, would reviewing it afterwards be good enough?

The MCP specification, the protocol many agent clients use to call tools, takes the same position. Its tools page says: "For trust & safety and security, there SHOULD always be a human in the loop with the ability to deny tool invocations." It asks applications to "Present confirmation prompts to the user for operations, to ensure a human is in the loop" (latest revision, 2026-07-28, checked in November 2026).

Which actions need approval

Sort actions by what it costs to get them wrong, not by how often the agent gets them right.

  • Reads: usually no approval. Scope what the agent can see instead (see least privilege for AI agents). A read can still leak data, so the guard is access, not a prompt.
  • Reversible writes: changing a status, fixing a typo, reassigning a ticket. A person on the loop with an undo is often enough.
  • Irreversible writes: hard deletes, purging files, closing an account. Approve first.
  • Money: refunds, credits, discounts, payouts, plan changes. Approve first, and consider a threshold so small amounts run and large ones wait.
  • Messages to customers: emails, SMS, replies on a ticket. Once sent, they cannot be unsent. Approve first.
  • Bulk changes: anything touching more than a handful of records. One approval for a clear, listed set is fine. One approval for "all matching records" is not, because the person cannot see what they approved.
  • Changes to access: roles, API tokens, permissions. Approve first, by someone other than the person who asked.

What a good approval request shows

The person approving has a few seconds and no context. Give them what they need to decide without opening five tabs.

  • The action in plain words: "Refund $240.00 to order #1042".
  • The record it touches, linked, with enough detail to recognise it.
  • Before and after values for every field that changes.
  • Why the agent proposes it: the request or message it is answering.
  • Who asked: the person or the job that started the agent.
  • Whether it can be undone, said honestly.
  • When the request expires.

If a request needs a paragraph of explanation to approve, split it into smaller requests.

A worked example in Rails

A support agent reads a customer's message, finds order #1042, sees a double charge and proposes a $240 refund. The refund should wait for a person. Here is one way to build that with plain Rails: the agent's tool writes a pending action, a person approves it, and only then does a job call the payment provider.

The table holds the proposal and who decided it. The jsonb column assumes PostgreSQL; use json elsewhere.

class CreatePendingActions < ActiveRecord::Migration[8.0]
  def change
    create_table :pending_actions do |t|
      t.string :kind, null: false
      t.references :subject, polymorphic: true, null: false
      t.jsonb :payload, null: false, default: {}
      t.text :reason
      t.string :status, null: false, default: "pending"
      t.references :requested_by, foreign_key: {to_table: :users}
      t.references :decided_by, foreign_key: {to_table: :users}
      t.datetime :decided_at
      t.datetime :expires_at, null: false
      t.timestamps
    end
  end
end

The model owns the rules: only a pending, unexpired action can be approved, and the decision is recorded with the person who made it. with_lock stops two people approving the same request at once.

class PendingAction < ApplicationRecord
  belongs_to :subject, polymorphic: true
  belongs_to :requested_by, class_name: "User", optional: true
  belongs_to :decided_by, class_name: "User", optional: true

  enum :status, pending: "pending", approved: "approved", rejected: "rejected", expired: "expired"

  scope :overdue, -> { pending.where(expires_at: ...Time.current) }

  def approve!(user)
    with_lock do
      raise "Not pending" unless pending?
      raise "Expired" if expires_at.past?
      update!(status: "approved", decided_by: user, decided_at: Time.current)
    end
    ExecutePendingActionJob.perform_later(self)
  end

  def reject!(user)
    with_lock do
      raise "Not pending" unless pending?
      update!(status: "rejected", decided_by: user, decided_at: Time.current)
    end
  end
end

The agent's refund tool does not refund anything. It creates the request and tells the agent it is waiting.

def propose_refund(order:, amount_cents:, reason:, requested_by:)
  PendingAction.create!(
    kind: "refund",
    subject: order,
    payload: {amount_cents: amount_cents},
    reason: reason,
    requested_by: requested_by,
    expires_at: 24.hours.from_now
  )
  "Refund of #{amount_cents} cents on order #{order.id} is waiting for approval."
end

The job checks the status again before acting, so a stale or rejected request never runs, even if something enqueues it by mistake.

class ExecutePendingActionJob < ApplicationJob
  def perform(action)
    return unless action.approved?

    case action.kind
    when "refund"
      Refunds.issue(action.subject, action.payload["amount_cents"])
    end
  end
end

Refunds.issue stands in for your own payment code. The approval screen is an ordinary controller and view that lists pending actions and calls approve! or reject! with current_user.

When nobody approves

A request that waits forever is a decision nobody made. Give every request an expiry and treat silence as no.

  • Default deny. An expired request does not run. The overdue scope above finds them; a scheduled job marks them expired with PendingAction.overdue.find_each { |a| a.update!(status: "expired") }.
  • Pick the timeout by action. A customer reply might expire in an hour, a refund in a day. Short enough that the data it was based on is still true.
  • Tell someone. When a request expires, say so to the person who asked, so the work does not quietly vanish.
  • Never approve by timeout. "Runs automatically unless rejected within 24 hours" is a person on the loop, not in it. Use it knowingly, if at all.

Logging who approved what

The pending actions table is already half of an audit record: what was proposed, why, by whom, who decided, when. Keep the rows; do not delete them after execution. Add the outcome of the job (success, the provider's reference, or the error), and you can answer "who let this refund through" months later. An AI agent audit trail covers the rest: the tool calls that led up to the proposal and the reads around it.

Approvals in an Avo app

Teams that run their back office on Avo get this pattern in the assistant itself. In Avo's AI chat, updates, deletes, action runs and CSV imports arrive as a card with each field's before and after values, and nothing changes until someone clicks Confirm. Confirmed writes are recorded and can be undone from the chat, and an action that emailed a customer is marked not undoable rather than pretending. Every call runs through the same Avo policies as the person chatting. Details are on the AI add-on page.

Frequently asked questions

What is human in the loop for AI agents?

It is a pattern where an AI agent prepares an action and a person approves or rejects it before it runs. The agent does the finding and drafting, and a named person makes the decision for actions that are costly, permanent or visible to customers.

What is the difference between human in the loop and human on the loop?

With a human in the loop, the action waits until a person approves it. With a human on the loop, the action runs straight away and a person reviews it afterwards, with the option to stop or undo it. Most teams use the first for money, deletes and customer messages and the second for reads and small reversible changes.

Which agent actions should need approval?

Actions that are hard or impossible to reverse: refunds and other payments, hard deletes, messages sent to customers, bulk changes and changes to permissions. Reads and small reversible edits usually do not need a prompt, as long as the agent's access is scoped.

What should happen if nobody approves an agent's request?

Nothing should run. Give each request an expiry, mark it expired when the time passes, and tell the person who asked. Never let a request approve itself by timing out.

How do I add an approval step in Rails?

Have the agent's tool create a pending action record instead of doing the work, with the proposed change, the reason and an expiry. A person approves it from a screen that records who decided and when, and only then does a background job perform the action, after checking the record is still approved.

Try Avo for free