Human-in-the-loop approvals for AI agents: the approval that arrives on Monday
It's Thursday, 4:52 in the afternoon, and your agent has finished a good day's work. A customer wrote in about a double charge. The agent found both charges, checked them against the refund policy, and drafted a $212 refund. That's the kind of action that deserves a human yes, so the agent stops and asks. Your user, who is a person with a calendar, glances at the request, thinks “I'll get to that,” and goes home. The yes arrives Monday at 9:14.
Here is what happened to the world in the meantime. You deployed on Friday to fix a bug, and again on Saturday because the Friday fix had a bug. The user closed the tab on Thursday night, along with the laptop it lived on. And on Friday afternoon somebody changed the refund policy, because somebody always changes the refund policy on a Friday afternoon. Now it's Monday, 9:14, and a click is arriving for a request made about 88 hours ago, in a world that no longer exists. What does the agent do with it? The question this post answers is how human-in-the-loop approval for AI agents actually works: what it takes for an agent to wait for a person's yes and still do the right thing when it arrives.
Waiting that long and still doing the right thing on waking takes one architecture, and it's the one we'd defend: the turn (the agent's work on one user message) is a recorded process, not a living thread. State is saved before every step, a waiting turn is parked at no cost, each side effect happens once and is never retried blind, outcomes nobody can vouch for are reported as unknown, and the resumed turn finishes under the configuration it started with. Somewhere in the middle a voice in your head is going to say “just use Temporal.” There's a section for the voice, and the voice is mostly right.
The Monday problem
Every approval request is a snapshot. It says: here is what I know, here is what I'm about to do, may I? The human's yes is a yes to that snapshot, and the snapshot starts aging the moment it's taken. Five seconds later the aging is invisible. Four days later it's the whole story. Call it the Monday problem: the approval arrives in a different world from the one it was asked in.
Filing this under timeouts would be a mistake, because a thirty-second wait can straddle a deploy and a user can close a tab in two. Filing it under infrastructure is half right, which is worse. Infrastructure answers whether the work can survive the wait. The other half asks whether it should, and that question tends to get asked for the first time when a refund goes out under a policy that died on Friday.
The naive version, and three ways it dies
Let's build it the way everyone builds it first. The agent reaches a step that needs approval, sends a request to the user's screen, and awaits the answer. It's one line of code and it reads beautifully. It also keeps the agent alive by keeping it running: the plan, every intermediate result, and the agent's place in line all sit in the memory of one process on one machine, wired to one browser tab. (In the demo the approval takes four seconds, so nobody ever finds out how this ends.)
It dies three ways, in ascending order of subtlety.
First, it dies when the process dies. A deploy, an autoscaler reaping an idle instance, a memory limit at 3 a.m. The plan, the half-read documents and the place in line go with the process, and on Monday a yes arrives for a request nobody remembers making. Our Thursday agent got two chances to die this way and only needed one.
Second, it dies when the page dies. The question was sent to a tab, and the answer is due back from the same tab. Close it and the question has no address. Worse, if the page was also driving the work, closing it quietly stopped the agent mid-thought, and the user can no longer tell “the agent is waiting for me” from “the agent is gone.”
Third, it dies when the world moves, and this is the death that never shows up on a crash dashboard. Suppose the process somehow survives. It's now holding a proposal that's 88 hours old, a policy that changed on Friday, and a user who may have asked something else entirely on Saturday. Resume it faithfully and you have built an excellent machine for doing the wrong thing correctly.
(There's a fourth death, less dramatic. Holding a worker, a socket and a slot in a connection pool for four days to wait on one human dies of the invoice.)
So the agent can't wait the way a function waits. It has to wait the way a letter waits: written down, put somewhere safe, and delivered when someone comes. Five rules fall out of that, and they escalate. Each one exists because the one before it wasn't enough.
Write it down first
The first rule is the unglamorous one: before the agent does a step, the state it's working from has to be written somewhere that outlives the process. Before each step, not at the end. The turn becomes a recorded sequence, round after round, and the process executing it becomes a disposable reader of that record. A runtime built this way records the result of each round before starting the next, so a crash or a deploy costs nothing: another machine picks the turn up from the last recorded round.
Once the record exists, a wait stops being something the process does and becomes something the record says. That's what makes Monday affordable. A turn that needs a yes writes down that it's waiting, and then it stops running. No worker, no socket, no timer on a machine somewhere. Give the human a few minutes, long enough for the common case of someone looking right at the screen. If nothing answers, the turn parks: it stops executing, keeps its place, and doesn't expire. A parked turn is, in spirit, a row in a table, which is the entire secret of waiting for free. You stop holding anything while you wait.
The record has a second job that's easy to miss. If the browser doesn't drive the conversation and only reads the log of it, then any number of tabs can show the same turn, a reload replays it from the start, and a dropped connection picks up where it left off. Closing the tab was a death in the naive version. Here it's a non-event, because the work was never in the tab. Only an explicit Stop cuts a working turn short.
Once, not at least once
The second rule is where agents part ways with workflows that only compute. When a turn resumes from its last recorded step, anything between that record and the crash might have happened or might not. Recomputing a sum twice costs nothing, but sending a $212 refund twice costs $212.
The standard back-office answer is at-least-once execution with idempotency keys: retry until you hear back, and let the receiving system recognize duplicates and drop them. It's a good answer when every endpoint your agent touches honors the key. Check that sentence against your own API before you rely on it, because “every” is a big word for an API that has been growing for years.
The other answer is to refuse to retry the action at all, then arrange things so you can afford the refusal. In our design that arrangement is physical: your product's actions run in the user's browser, not on our servers, and each one is recorded once. The browser is where the user's session already lives, so the action happens as the user, under the user's permissions, and your backend checks them as it would any request from that user. It also means each action has exactly one place it can come from. A page never runs the same request twice, and a recovered turn doesn't resend what the record says went out. The user's message is never sent twice, and an action never reaches your backend twice.
This is where the house line lives: a half-finished batch is worse than none. Say the agent is cancelling forty subscriptions for a customer who's leaving. A batch that never started is a clean state, so you try again. A batch that finished is a clean state too. A batch that cancelled twenty-three, crashed, and restarted from the top is a puzzle. Which twenty-three? Were the first eleven cancelled twice, and what does cancelling twice do in your billing system? The half-finished batch is the Monday problem in its most expensive form, because it's the state in which neither the system nor the human can say what's true.
Unknown is an answer
Rule three follows from rule two. If you refuse to retry blind, you will eventually hold a request whose fate you honestly can't state: the page closed in the middle of it, or the connection dropped after the request left, and nobody can say whether your backend ever saw it. A system has three options. It can assume the request worked, and risk telling the user about a refund that never happened. It can assume the request failed, and risk issuing the refund twice. Or it can say that it doesn't know. The first two are guesses wearing a status code. Only the third is true.
If a page closes in the middle of a request, don't retry it. Tell the model the outcome is unknown, and let its next move be asking the user to check. It's the least impressive-sounding rule on the list. The agent says “I can't confirm that went through; could you look at the refunds tab?” and a human spends nine seconds looking. Nine seconds of human attention is the cheapest insurance in this entire post.
The same honesty applies to stopping. A Stop button feels like undo, and it isn't one. A good one takes effect within seconds, even mid-round, cancelling the round and the tool calls running in it, and then it reaches its limit: a request your backend has already received can't be called back. Stop means “do nothing further,” not “unsay what's been said,” and the interface should say so, because a Stop that promises more than it delivers is just one more way of guessing.
The rules you started with
Rule four is about Monday itself. The yes arrives and the turn resumes. Under which rules? Suppose the agent started Thursday with certain instructions, a certain model, and a certain list of things it may do. On Friday someone edited all three and published. You can resume the old turn under the new configuration, which means the human approved one agent and a different one is executing. Or you can finish the old turn under the configuration it started with and let the changes govern the next turn. The second is the one to pick: a turn resumes with the configuration it started with, and publishing a change affects the next turn, never one already running.
The reasoning is the reasoning of contracts. A yes is a yes to something specific, and if the thing underneath can change after the yes, then the yes was to nothing. The approval covers the request the user was shown, and the agent carries out that request instead of improvising about what Monday's version of the policy would have wanted. (Reference material is a separate question, and worth deciding on purpose: documents read as the turn goes can change even when the instructions don't.)
This cuts both ways. If Friday's change was a fix for something dangerous, Monday's turn doesn't get it, which is why the approval can't be the last check. Take the refund policy. If it lives only in a prompt, Monday's refund goes out under Thursday's rules. If it lives in your backend, which checks the user's permissions and its own rules when the action arrives, a Monday refund that breaks Friday's policy gets refused, and the agent can say so. A stale yes should have to get past something that isn't stale.
The user moving on
Rule five surprised us most when it clicked. A parked turn is waiting on the user, but the user isn't necessarily waiting on the turn. Say that on Sunday night our user opens the conversation and types a new message: “actually, just give the customer a credit.” Now two things exist: an old request asking for a yes to a refund, and a new instruction that contradicts it. If the old request stays live, Monday's click from a second tab somebody forgot to close does exactly what the user told the agent not to.
So the user's next message supersedes the wait. When a user moves on with a new message instead of answering, the unanswered requests are abandoned and the turn ends as superseded. Note how those requests get recorded: as unknown outcomes, the same label as before. Not declined, not failed. The rule hasn't changed since rule three, which is the point of having a rule. A new message is the strongest signal the system will ever get about what the user wants right now, and it outranks a request made on Thursday.
Where the yes happens
Everything so far was the machine's side of the wait. The human's side is where plenty of careful designs quietly stop. A durable engine can hold a turn for as long as you like, but it can't make the answer trustworthy. Who gave the yes? As whom? Were they looking at the thing they approved? Suppose the approval is a Slack button, a link in a message that posts to a webhook. The checkpointer will faithfully resume the turn when the webhook fires. It has no opinion on whether the click came from the manager, from the manager's intern, or from a link pasted in the wrong channel. A checkpointer does nothing for you if the approval is a button nobody can authenticate.
The yes has to happen somewhere that knows who's answering and can show them what they're answering. In the runtime we build, that place is the conversation itself, in the page where the user is already signed in. An ask-first action waits in that thread for the user's yes, and when it runs, your backend checks the user's own session. When the user comes back on Monday, the page opens the conversation, shows the pending request in the thread they left, and carries it out once the yes is given.
The nahhh section
Time for the voice. It has some good arguments, and we'll give it all of them.
Nahhh #1: “Just use Temporal. Or LangGraph.” Steelman first. Temporal's own cookbook says a workflow waiting on a human signal can wait for hours, days or indefinitely while consuming no compute, and it rebuilds its state after a crash by replaying a stored event history. Inngest, Restate and Trigger.dev each have their own version of the trick: a step that parks until an event arrives, an awakeable is resolved, or a token is completed. LangGraph gives you an interrupt, a checkpointer and a thread ID, and will wait as long as the checkpointer remembers. These are serious tools. For back-office orchestration (the nightly reconciliation, the multi-day onboarding pipeline, the approval chain that lives entirely inside your own systems) they are the right answer, and if that's your problem you should stop reading and go install one. We mean it.
What they don't ship is the layer where the human is. An engine will wake the turn when the signal arrives. It doesn't decide who is allowed to send the signal, what that person was shown, what to do about a browser action whose tab closed mid-flight, or whether the user's next chat message should cancel the wait. You write those parts, and plenty of teams write them well, but they're a layer of their own: an approval screen, an identity story, a rule for superseding, a convention for unknown outcomes. The engine will cheerfully run all of it wrong, durably.
Two honest footnotes on the price of admission. Temporal replays your workflow code against its history, so the code has to be deterministic. LangGraph restarts the whole node from its first line when the graph resumes, so anything before the interrupt runs again, and its docs tell you to make those side effects idempotent. Neither is a flaw, and both are the engines telling you plainly what rule two costs on their side.
Nahhh #2: “Retries plus idempotency keys. Solved years ago.” Conceded, if every endpoint honors the key, and in that world retrying is simpler than anything above. Two things survive the concession. Someone still has to decide what the user is told when a retry is still failing on Monday, which is the unknown-outcome rule again. And the day one endpoint doesn't honor keys, which is traditionally the endpoint that moves money, you'll want rule two sitting there.
Nahhh #3: “Just expire the request after 24 hours. Old approvals are dangerous.” Correct instinct, wrong verb. Staleness is real, and a human or the user's own next message should be able to retire a request. But “expired” and “failed” are statuses the system invents, and the person on the other end reads “failed” as permission to do it by hand, which is how the double refund happens. If your product wants a clock, make it a policy you chose, and have it say “nobody answered,” not “it didn't work.”
A fair map of the field
Three classes, alphabetical within each, one line apiece. The lines come from each project's own docs, which is the fairest source available and also the one most likely to flatter them.
Durable workflow engines. Inngest: steps that park on a wait for a matching event or a timeout, with human approval named in its docs as a use. Restate: handlers that suspend on an awakeable, a one-shot signal with an ID that a person or service can resolve through its API. Temporal: workflows that wait on signals for as long as needed and resume by replaying an event history, which asks your code to be deterministic. Trigger.dev: waitpoint tokens that pause a run, using no compute while it waits, until your backend or UI completes the token or it times out.
Agent-graph frameworks with checkpointers. LangGraph: an interrupt that saves graph state and waits for a resume keyed by thread ID, which wants a durable checkpointer in production and reruns the interrupted node from its start.
Embedded agent tools. CopilotKit: mostly a toolkit, with open-source UI components and hooks plus a runtime you host. It renders the approval inside your app and hands the user's answer back to the agent, and for durable state across a long wait its own docs point to the agent framework underneath. This is the youngest class, and it stretches from toolkits that hand you the interface pieces to platforms that bring a hosted runtime too. We build in the platform half, so apply the usual discount to anything we say about it.
The three aren't rivals so much as layers. Most real systems end up with two of them, and the useful question is which layers you'd rather write yourself.
Longer than a request
Back to Monday, 9:14. The user clicks Approve. The turn wakes, reads its own record, and finds the refund request exactly where Thursday left it, under Thursday's rules. The action goes to your backend once, as the signed-in user, and your backend either issues the refund or explains why it won't. Had a page closed mid-request, the agent would say it couldn't confirm the outcome and ask someone to look. Nobody in the room learns there were two deploys. That's what success looks like here: a Monday with nothing to report.
Notice how small this case is, though. It's one approval, from one person, who answered within a weekend. Most business processes outlast an HTTP request, and plenty outlast a weekend. A vendor onboarding waits on legal, then procurement, then somebody who is on leave until the 14th. A migration waits on a maintenance window that opens in nine days. An agent that can't wait through any of that can only do the parts of work that contain no humans, which are, conveniently, the parts nobody was struggling with.
That's the shape of the problem we built Teaflask around: an agent that parks instead of holding on, records before it acts, says “unknown” when it doesn't know, and finishes the job it was approved for. The product side is on our durable execution page, and the mechanics, in less entertaining order, are in the architecture page of our docs.