Skip to content

How to build a human-in-the-loop AI agent

Learn how to build a human-in-the-loop AI agent: where to add checkpoints, how to reach people on Slack, Telegram or email, and how to handle every answer.

How to build a human-in-the-loop AI agent
Author:Victor Yakubu
Victor Yakubu

The appeal of an autonomous agent is that it doesn't wait for you. It reads the ticket, writes the fix, runs the tests and moves on to the next one while you're doing something else.

That's exactly what you want, right up to the step that moves money, touches the production database or emails a customer. There, speed stops being the point. The question someone will ask afterwards is simple: who approved this?

A human-in-the-loop agent can answer it. It does the routine work alone, stops at chosen points to get a decision from a person, and carries on based on what they said. It stays fast where that's safe and waits where it isn't.

Building one takes three things: checkpoints in the right places, a way to reach the person wherever they are, and code that handles every outcome, including a no and no answer at all. This guide covers all three, with working examples you can drop into an agent today.

Where to put checkpoints

Too many checkpoints and the agent is no faster than doing the work yourself. Too few and it deletes the wrong table. In practice, three kinds of moments earn a checkpoint.

  • Missing information only a person has. Which environment to deploy to, which customer a ticket is about, what the budget is. The agent can't find it in a file, so it asks.
  • Actions that are hard to undo. Deleting data, deploying to production, spending money, sending something other people will read. Ask for an explicit approval here even if a person is watching the chat, because an approval records who agreed and when.
  • Choices that are expensive to get wrong. Two reasonable options, like a canary or a blue-green rollout, where picking the wrong one quietly means redoing the work. Offer the options instead of choosing silently.

A useful test: if you'd want to be told before a new colleague did it, the agent should ask too.

And one moment that needs no answer: when a long job finishes, tell someone. It's not a checkpoint, but it's the same plumbing, and it saves people checking logs.

The reverse matters just as much. Don't add a checkpoint for something the agent can decide from the code or the docs, and don't ask through a separate channel when the person is already in the chat and can answer the next message.

Which kind of checkpoint to use

SituationUseExample
The agent needs a fact it can't findask"Which customer is this ticket about?"
The next step is hard to undoApprove"Delete 342 stale rows from the orders table?"
There are a few reasonable optionschoose"Canary or blue-green rollout?"
Someone should know, but no answer is neededtell"Nightly build finished, 0 failures."
The same kind of action keeps coming upapprove with an extra button"Allow this tool for the rest of the session?"
A person in the chat can answer right nowAsk in the chatSkip the inbox

Why pausing isn't enough

Most agent frameworks can pause. LangGraph has interrupts, and most others have some way to stop a run and save its state until input arrives. That solves the easy half of the problem.

The pause itself is usually solid. Stop the process while it waits and the state survives. What a pause doesn't do is tell anyone a question is waiting, or wake the run back up when the answer arrives.

A pause leaves four questions open:

  1. Delivery. How does the person find out there's a question, if they aren't watching the terminal?
  2. Answering. How do they answer from wherever they are, without opening a laptop and a dashboard?
  3. Waiting. What does the agent do while it waits, and what happens when the answer comes after the process has stopped?
  4. Accountability. Who answered, when, and on which channel? Without that, an approval is just a boolean.

Teams usually answer these by building a Slack bot, an email sender, a table to match replies to questions, and timeout logic, then rebuilding it for the next agent. The rest of this guide uses Agent Inbox, which we built at Novu to handle those four parts, so the agent code only has to decide where to ask.

Build it step by step

The examples use the Agent Inbox CLI, which any agent that can run a shell command can call. If your agent runs in Claude, ChatGPT or Cursor instead, the same steps work through MCP; see the end of this section.

Step 1: Give the agent an inbox

npx @agentinbox/cli setup

This creates the agent's inbox and gives it an email address. Connect Telegram or Slack when it offers, or later with npx @agentinbox/cli channel add telegram. Questions go to you by default, on your preferred channel.

You can start without an account, but a keyless setup gets only a few free messages and can't send email. Run npx @agentinbox/cli login to claim it before you rely on it.

Step 2: Ask for missing information

When the agent needs a fact only a person has, it asks a free-text question. The command waits for the answer and prints it.

ENVIRONMENT=$(npx @agentinbox/cli send ask "Which environment should I deploy to, staging or production?")
echo "Deploying to $ENVIRONMENT"

Step 3: Get approval before anything hard to undo

An approval sends Approve and Deny buttons and waits. Set --ttl so a stale yes can't be used hours later, and --from so the person knows which agent is asking.

npx @agentinbox/cli send approve "Delete 342 stale rows from the orders table?" \
  --from cleanup-bot --ttl 2h

You can rename the buttons and give the person more context:

npx @agentinbox/cli send approve "Issue the refund?" \
  --subtitle "Order ORD-42" --body 'Refund $25.00' \
  --approve-label "Refund" --deny-label "Don't refund"

Approve a kind of action, not just one

Some agents ask for the same thing over and over, like running a particular tool. Asking every time wears people down until they stop reading the questions. Add a third button so the person can approve the whole kind of action once:

RESULT=$(npx @agentinbox/cli send approve "Run the database migration tool?" \
  --subtitle "migrate: add index to orders.created_at" \
  --extra-action trust-tool:"Always allow this tool" \
  --json) || exit 1   # denied (10) or expired (12): stop here

case $(echo "$RESULT" | jq -r '.response.optionId') in
  trust-tool) TRUSTED_TOOLS="$TRUSTED_TOOLS migrate"; ./migrate.sh ;;
  approve)    ./migrate.sh ;;
esac

Pressing Always allow this tool counts as an approval, so the command exits with 0 just like Approve. To tell the two apart, read the button's id from the JSON result: response.optionId is approve for the Approve button and trust-tool for yours. Without --json, the output only says who approved, not which button they pressed.

Agent Inbox doesn't remember that choice for you, and neither does listen. The button is just an id, so your agent has to act on it, for example by keeping a list of trusted tools for the run and skipping the question for them next time. Through MCP, inbox_send takes up to four extra buttons in extra_actions, and the result names the one pressed: Approved by Ada. They chose: trust-tool.

Keep the wider permission narrow: one tool, one session, one kind of action. "Always allow everything" defeats the point of the checkpoint.

Step 4: Offer a choice

For a decision between options, choose sends up to 10 of them. On its own, it prints a sentence with the label the person picked, like Chose "Staging" by Ada at 1:05:00 PM. To branch in code, give each option a stable id and read it from the JSON result, so your code doesn't depend on the wording.

STRATEGY=$(npx @agentinbox/cli send choose "Pick a rollout strategy" \
  --option canary:"Canary, 5% of traffic first" \
  --option blue-green:"Blue-green, switch all at once" \
  --json | jq -r '.response.optionId')

echo "Rolling out with $STRATEGY"   # canary or blue-green

Step 5: Handle every outcome

This is the step most implementations skip. A question can end four ways, and the exit code tells your code which one:

Exit codeMeaningWhat the agent should do
0Answered or approvedGo ahead
10DeniedStop, and report that it was denied
11The wait ran out, but the question is still openStop waiting; the answer will land in the conversation
12Expired or canceledTreat it as not approved
1Error, such as no inbox set upReport the error; don't retry blindly

In a shell script:

npx @agentinbox/cli send approve "Deploy v2.4.1 to production?" --timeout 10m
code=$?

case $code in
  0)  ./deploy.sh production ;;
  10) echo "Deploy denied. Stopping."; exit 1 ;;
  11) echo "No answer in 10 minutes. The question is still open."; exit 0 ;;
  12) echo "Approval expired. Not deploying."; exit 1 ;;
  *)  echo "Agent Inbox error. Check the setup."; exit 1 ;;
esac

The same logic from Python, if your agent is written there:

import subprocess

result = subprocess.run(
    ["npx", "@agentinbox/cli", "send", "approve",
     "Deploy v2.4.1 to production?", "--timeout", "10m"],
    capture_output=True, text=True,
)

if result.returncode == 0:
    deploy("production")
elif result.returncode == 10:
    report("Deploy denied.")
elif result.returncode == 11:
    report("No answer yet. The question is still open.")
elif result.returncode == 12:
    report("Approval expired. Not deploying.")
else:
    report(f"Agent Inbox error: {result.stderr}")

Two things to note. First, a timeout doesn't lose the answer. When the person replies later, it lands in the conversation, and the agent finds it with npx @agentinbox/cli show <conversation-id>. Every send prints the conversation id, so keep it. Second, treat an expired approval as a no. A yes that arrives after its window closes shouldn't trigger the action.

Step 6: Ask the right person

The decision isn't always yours. List the agent's contacts, then send the question to the person who owns it. Send it to several people and the first valid answer wins.

npx @agentinbox/cli contact list
npx @agentinbox/cli send approve "Refund $240 to Acme for order 8831?" --to maya
npx @agentinbox/cli send approve "Hotfix needs a production deploy. Approve?" --to dana,lee

To add someone, npx @agentinbox/cli contact invite <name> creates a link they open to connect the channel they prefer.

In Claude, ChatGPT or Cursor: use MCP

Add https://mcp.agentinbox.md as an MCP server and sign in. The agent then calls inbox_send with kind set to ask, approve or choose. A tool call can only wait about 45 seconds, so if nobody has answered by then, the result says the request is still open and gives an id. The agent calls inbox_wait with that id until the answer arrives, and shouldn't go ahead without it.

For coding agents like Claude Code, Codex and Cursor, install the skills, which teach the agent when a checkpoint is worth it and when to just ask in the chat:

npx skills add agentinboxhq/skills

Common patterns

Deploys. Gate the production step on an approval with a short --ttl, and send the approval to whoever is on call rather than whoever started the job. Report the result with a plain send when it's done.

Deletes and migrations. Put the numbers in the question, like "Delete 342 rows" rather than "Clean up the table". A person can't judge what they can't see.

Payments and refunds. Use --subtitle and --body for the order and the amount, send it to the person who owns the budget, and treat expiry as a no.

Outbound messages. Before an agent emails a customer or posts somewhere public, send the draft as the question and ask for approval. It's cheaper than an apology.

Long-running jobs. Send a plain message when the job finishes or fails. It doesn't wait, and nobody has to watch the logs.

Oversight is what lets agents do more

It's tempting to see checkpoints as a brake. In practice they work the other way. Teams hold agents back from risky work because there's no safe way to let them near it. Once every risky step goes through an approval that's recorded and easy to answer from a phone, you can hand the agent more of the job, and loosen the checkpoints as it earns trust.

A checklist for every checkpoint

  • The question says exactly what will happen, with numbers and names.
  • It goes to the person who owns the decision.
  • Approvals have a --ttl that matches how long a yes stays valid.
  • The code handles approved, denied, timed out, expired and error.
  • Expired is treated as no.
  • The agent keeps the conversation id so it can find a late answer.
  • The agent doesn't ask about things it can decide itself.

Start with one: pick the riskiest action your agent already takes, and put an approval in front of it.

FAQ

What is a human-in-the-loop AI agent?

A human-in-the-loop AI agent stops at set points to get an answer or a decision from a person, then continues based on that answer. It handles the routine work alone and leaves risky or ambiguous calls to people.

When should an AI agent ask a human?

An agent should ask when it needs information only a person has, before actions that are hard to undo like deploys, deletes and payments, and when a choice between options is expensive to get wrong. It shouldn't ask about things it can work out from the code or docs.

What happens if nobody answers the agent?

The agent should stop waiting after a set time and treat the action as not approved. With Agent Inbox, the command exits with code 11 when the wait runs out, the question stays open, and a late answer lands in the conversation for the agent to find later.


Isn't pausing in LangGraph or another framework enough?

Pausing saves the agent's state, but it doesn't tell anyone a question is waiting or bring the answer back. You still need to deliver the question to the person, collect the answer and resume the agent, which is the part an agent inbox handles.


How do I record who approved an agent's action?

Use an approval that returns who answered and when. Agent Inbox sends Approve and Deny buttons on email, Telegram or Slack and returns the answer with the person and the time, so every approval leaves a record.


Read More

You’re five minutes away from your first Novu-backed notification

Create a free account, send your first notification, all before your coffee gets cold... no credit card required.