You have decided some tool calls need a human. Now you have to actually stop, and that turns out to be the part that shapes the whole design.
Part 1 · The obvious implementation, and why it fails in production only
A human might take five seconds to decide. Or five minutes. Or they wander off and come back tomorrow.
A serverless function gets about 60 seconds and then it dies.
So the obvious implementation:
// ❌ works in dev, works in staging, dies in production
const decision = await waitForHumanClick(runId);
if (decision === "approve") await runTool();…holds the HTTP connection open and awaits the click. It works perfectly on your laptop. It works for fast clicks in staging.
There is a second problem with holding the connection, and it is worse: the machine holding it can be recycled at any point. Even within the timeout, the thing you are relying on staying alive is not something you control.
Part 2 · So don't hold anything open. End the request.
Part 3 · Why this is so little code
Because of something project #2 established for a completely different reason:
The agent loop's entire state is the
messagesarray. There is no hidden memory, and no session on Anthropic's side.
So persisting an agent is persisting an array.
// pause: the loop stops and hands you its state
yield { type: "approval_required", calls: pending, messages };
// ...one jsonb column later...
// resume: read it back, append the results, call the loop again
const messages = [...run.messages, toolResultMessage(results)];
runAgentLoop({ messages, toolbox, iterationOffset: run.iterations });There is no continuation, no serialised generator, no coroutine library, no workflow engine.
The schema is not exotic either
| Column | Holds |
|---|---|
id | uuid, the run |
status | running / awaiting_approval / done / error |
messages | jsonb — the agent's entire mind |
iterations | how many trips it has already taken |
pending_calls | jsonb, what it wants to do |
One row. The interesting column is messages, and it is doing double duty: it
is the pause mechanism and, for free, a complete record of the conversation
as the model saw it — which is most of what replay needs.
Part 4 · The seatbelt that unbuckles itself
One detail, very easy to miss, that costs real money.
runAgentLoop({ messages, toolbox, iterationOffset: run.iterations });Without iterationOffset, iteration starts at 0 again on every resume. A run
that pauses nine times gets nine fresh budgets of ten iterations — so the
cap you thought was 10 is actually 90.
A seatbelt that unbuckles itself every time you stop the car is not a seatbelt.
And nothing fails. There is no error, no warning, no failed test. The symptom is a bill.
Part 5 · Two smaller things the pause needs
Nothing in the batch runs
If the model asks for three tools and one of them is gated, all three wait.
Running the two safe ones first would be faster. It would also mean that clicking Deny leaves you in a world where half the batch already happened — so the thing you refused did not occur, but its two siblings did, and the agent's next step is reasoning about a partially-applied state nobody chose.
Double-clicking Approve must not run the tool twice
For smash_jar that is harmless. For charge_card it is not.
The resume route checks the run's status and returns 409 Conflict on the
second attempt. The state machine is the idempotency key:
update runs set status = 'running'
where id = $1 and status = 'awaiting_approval'
returning *If that returns no row, somebody already resumed it. Same trick as project #1's cookie-eating statement — the guard and the write are one indivisible operation, so two clicks cannot both pass the check.
Part 6 · Why Postgres, and why the serverless driver
Why a database at all? Because you need a durable place for one object that
has a status. A run is a row; approving it is a conditional update; the whole
concurrency story is where status = 'awaiting_approval'. Reaching for a queue
or a workflow engine buys a lot of machinery to solve a problem the database
already solved.
Why @neondatabase/serverless rather than pg? Because a connection
pool is a great design for a server that boots once and runs for months,
and a serverless function is the opposite. "Open a pool at boot" becomes "open
a pool per request", and a traffic spike walks straight into:
FATAL: sorry, too many clients already
The serverless driver holds no connection at all — every query is an ordinary HTTPS request. No pool, no sockets to leak, works unchanged in a route handler or a script.
What you now know
- Holding the HTTP connection open works in dev and fails in production for exactly the decisions people think hardest about.
- The agent's whole state is its
messagesarray, so pausing is anINSERTand resuming is a function call with a bigger array. - The resumed loop cannot tell it was paused — which is why this needed no continuation library.
- Re-scope every counter when you split an operation across requests.
iterationOffsetis not optional. - Gate the whole batch, and make resume idempotent with a conditional
update returning
409. - Serverless needs a driver that holds no connections — and that costs you interactive transactions, which you replace with single indivisible statements.