Back to Insights

GenAI · Agent Engineering

Your Agent Is a Contractor, Not a Chatbot

You don't want a photo of every tile. You do want a call before anyone knocks down a wall. That sentence is the whole design of a business agent.

Dan Stativa

Putting an agent somewhere it can spend money?


AGT Agent Engineering

Stop on damage, not on doubt

Every framework ships human-in-the-loop now, and it is three lines of code. Deciding where those lines go is the real work — and the right question is not how unsure the model is, but how hard the action is to undo.

  • Why reviewing the final output is the missing-wall mistake
  • 40 steps at 99% each is a 67% success rate
  • The three buckets that write your approval UI for you
  • LangGraph interrupts in Python and TypeScript, plus the replay trap

You hired someone to redo your kitchen. You are at work all day.

You don’t want a photo of every tile. You do want a phone call before anyone knocks down a wall.

That’s the whole problem with business agents. And there are only two ways to get it wrong.

Call about everything, and you’re doing the job by phone. Call about nothing, and you come home to a missing wall.

Every framework now ships the phone call. In LangGraph it’s interrupt(). It’s about three lines of code.

Deciding where to put those three lines is the actual work. That’s what this post is about.

Why “just review the output” fails

The first thing everyone builds: let the agent run, look at the result, approve it.

It feels efficient. It’s the missing wall.

Here’s why. An agent task is a chain of steps. Read a request, call an API, write a record, send an email. The task only works if every single step works.

So the math is just:

task_success = step_reliability ** number_of_steps

That’s it. Nothing to tune, no hidden constants.

Now put real numbers in. Say you built a procurement agent. It reads a request, checks a budget, queries two vendor APIs, drafts a purchase order, writes to the ERP, and emails the supplier. Count the retries and tool calls and you’re around 40 steps.

Steps98% per step99% per step99.5% per step
590.4%95.1%97.5%
1081.7%90.4%95.1%
2560.3%77.8%88.2%
4044.6%66.9%81.8%
10013.3%36.6%60.6%

Look at the 40-step row. At 99% per step — a genuinely good agent — one run in three comes out wrong. At 98%, more than half.

This surprises people because 99% sounds great. It is great, for one step. It’s terrible for forty.

But the failure rate isn’t even the main problem. The main problem is when you find out.

If the agent goes wrong at step 6 and you only check at step 40, then steps 7 through 40 were all built on top of a mistake. You can’t patch that. You rerun the whole thing.

Now split the same job into four chunks of ten, with a check after each. Each chunk succeeds 90.4% of the time instead of 66.9%. Better — but that’s not the real win. The real win is that when something breaks, you catch it in the chunk where it broke, fix that, and keep going.

Checkpoints don’t make your agent smarter. They make being wrong cheap.

Which steps get the call

So which steps do you stop on?

The obvious answer is “the ones the model is unsure about.” That’s wrong, and it’s the mistake in almost every first version.

Confidence and damage aren’t the same thing. Gate on confidence and you get a queue full of nervous questions about harmless stuff — while the agent confidently does the one thing that costs you money.

Gate on how hard the action is to undo. Sort every action your agent can take into three buckets:

BucketExamplesWhat to do
Easy to undoreading data, calculating, drafting textnever stop. A gate here is pure friction
Annoying to undowriting to a database, updating a ticket, internal Slack messagebatch them. One stop covering ten actions
Can’t undomoving money, emailing a customer, deleting things, anything legalalways stop, one at a time, before it happens

That third bucket is your load-bearing wall.

Here’s the useful part: it’s short. In most workflows it’s under a dozen actions. Writing that list takes an afternoon, and it’s worth more than any prompt engineering you’ll do on the project.

Same set of actions, two different ways of sorting them, completely opposite results. Plato had a phrase for this kind of thing — carving at the joints — though he was talking about definitions, not purchase orders.

One thing worth saying out loud: if everything your agent does lands in bucket three, you haven’t built an automation. You’ve built a drafting tool. That’s still useful. Just don’t let anyone sell it as autonomy.

Building it

Let’s look at what interrupt() actually does, because the name undersells it.

When your code calls interrupt(), LangGraph stops the graph right there. It saves the entire state — every variable the graph is tracking — and hands control back to your application. The run isn’t blocked or sleeping. It’s gone. Nothing is holding a thread or a connection.

Later, you call the graph again with a Command(resume=...). LangGraph loads the saved state back up and continues. Whatever you passed as resume becomes the return value of that original interrupt() call.

Two things are required for this to work:

  • A checkpointer. This is what saves the state. In dev you use an in-memory one; in production it’s Postgres or similar, because the state has to survive a deploy.
  • A thread ID. Every run gets one. It’s how LangGraph knows which paused run you’re answering.

Here it is in Python:

from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph


def issue_purchase_order(state):
    po = state["draft_po"]

    # Bucket three: money moves. Always stop here.
    # Send the reviewer real data, not a yes/no question.
    decision = interrupt({
        "action": "issue_purchase_order",
        "vendor": po["vendor"],
        "amount": po["amount"],
        "budget_remaining": state["budget_remaining"],
        "changes_since_last_order": po["diff"],
    })

    if not decision["approve"]:
        return {"status": "declined", "reason": decision.get("reason")}

    # They might fix the draft instead of just approving it.
    return {"status": "issued", "po": decision.get("edited_po", po)}


builder = StateGraph(State)
builder.add_node("issue_purchase_order", issue_purchase_order)
# ... edges ...

graph = builder.compile(checkpointer=InMemorySaver())

config = {"configurable": {"thread_id": "po-4172"}}
result = graph.invoke({"request": "40 laptops, Dell"}, config=config)
# The run is now parked. result carries whatever you passed to interrupt().

# Hours later, when someone clicks approve in Slack:
graph.invoke(Command(resume={"approve": True}), config=config)

TypeScript is the same shape:

import { interrupt, Command, MemorySaver, StateGraph } from "@langchain/langgraph";

async function issuePurchaseOrder(state: AgentState) {
  const po = state.draftPo;

  const decision = interrupt({
    action: "issue_purchase_order",
    vendor: po.vendor,
    amount: po.amount,
    budgetRemaining: state.budgetRemaining,
    changesSinceLastOrder: po.diff,
  });

  if (!decision.approve) {
    return { status: "declined", reason: decision.reason };
  }
  return { status: "issued", po: decision.editedPo ?? po };
}

const graph = builder.compile({ checkpointer: new MemorySaver() });

const config = { configurable: { thread_id: "po-4172" } };
const result = await graph.invoke({ request: "40 laptops, Dell" }, config);
console.log(result.__interrupt__);

await graph.invoke(new Command({ resume: { approve: true } }), config);

Four decisions in that code are the actual design work.

Send data, not a question. interrupt("Approve?") gives you a yes/no dialog. People click through those by day two — you’ve all done it. Send the vendor, the amount, the remaining budget and what changed since last time, and your UI can show something a person can actually judge in a few seconds. The payload is the interface.

Let them edit, not just approve. Whatever you pass to resume comes back into the node. So {approve: true, edited_po: {...}} is no harder than true. If approvers can only say yes or no, every small correction turns into a whole new run.

Resume can happen much later. Because the state is in the checkpointer under a thread ID, someone can approve from Slack, from an email button, or from a queue at 9am tomorrow. Nothing is waiting. This is what makes gates survivable in a company where approvers have day jobs.

Batch the middle bucket. One stop showing ten pending database writes catches the same errors as ten separate stops, at a tenth of the interruption.

Now the trap, and it will bite you.

When you resume, the node runs again from its first line. LangGraph doesn’t snapshot the middle of your function — it replays the whole node and feeds your resume value into the interrupt() call when it reaches it again.

So any code above the interrupt runs twice. If you charge a card, send an email, or POST to an API before the interrupt(), you’ll do it twice.

Put side effects after the interrupt. Or make them idempotent. Otherwise your supplier gets two purchase orders and you get a long Monday.

What this costs

Gates aren’t free, and the cost doesn’t land on the vendor who sold you the framework.

Every stop you add is queue time and somebody’s attention. Gate a workflow at forty points and you haven’t automated anything — you’ve created a job where a person clicks forty times and gets blamed for whatever they waved through.

That’s a real design failure, not a compromise. A reviewer looking at three purchase orders a day catches the bad one. A reviewer looking at forty before lunch is a rubber stamp with a name attached. And when it goes wrong, that name is what the postmortem points at.

The strongest argument against all of this: it’s temporary. Per-step reliability keeps improving. At 99.5% the forty-step run is already 81.8%, and at 99.9% it’s 96%. If that continues, gates become the friction that outlived its reason — like the confirmation dialogs we’re all still clicking. Someone who believes this would build the automation now and let the models catch up. They might be right.

I still build the gates, for two reasons. Step counts are growing at least as fast as reliability, and reliability ** steps doesn’t care which number moved. And the third bucket — money, customers, deletions — isn’t waiting on a percentage. It’s waiting on a decision about who’s accountable.

Where the math breaks: it assumes every step has to be right. Plenty of agent work isn’t like that. Retries, sampling several attempts and picking one, independent subtasks that don’t feed each other — those don’t compound. And you can’t read per-step reliability off a dashboard. You get it by sampling your own traces and counting how often you had to correct something. If you haven’t done that, every number in my table is somebody else’s agent.

Closing

The contractor thing holds up because this was never really a technical problem. It’s the oldest question in delegation: what can you do without asking me, and what must you never do without asking me.

Frameworks made the asking easy. interrupt() is three lines. What they can’t tell you is which wall is load-bearing — that depends on your business, your customers, and who signs off today.

So the work isn’t building the phone call. It’s writing the list of things worth being called about.

That list is short and boring. It’s also the most valuable thing you’ll produce on the project.

Most teams write it after the first incident.

References

  1. LangChain, “Interrupts” — Python
  2. LangChain, “Interrupts” — JavaScript
  3. LangGraph, “Add human in the loop”

Dan Stativa

Putting an agent somewhere it can spend money?