A WhatsApp bot human handoff can look successful in the support dashboard while an old automated reply is still on its way. The agent starts helping, then the bot sends a product suggestion that ignores everything the agent just said.
Turning off new AI jobs doesn’t stop the one already running. We need the sender to know who owns the conversation at the moment it submits a reply.
We’ll begin with a conversation owner. Then we’ll add the check that makes that owner matter, and look at the small external-send gap we still have to manage.
Make ownership an application state
Store a conversation mode such as bot, handoff_pending or human. Keep an owner ID and an increasing epoch alongside it. The epoch is a version of the ownership decision.
A handoff request updates that row in a transaction. It should also record who requested the handoff and why. Don’t infer ownership from whichever application last displayed the chat.
When a bot job starts, it captures the current epoch. A handoff changes the epoch, so the job can later recognise that its permission to reply is old. Keep that separate from the customer’s message revision: a new message and a new owner are different events.
Check ownership where messages leave the system
Checking only before generation isn’t enough. A model call can take time, and the owner can change during that call. Run the ownership check again at the sender.
The sender should receive a proposal, its conversation ID, the ownership epoch and the source input IDs. It should reject a bot proposal when the current mode is human or the epoch no longer matches.
Make all automated send paths use that sender. A promotional follow-up or delayed retry that bypasses it can recreate the same problem after you’ve fixed the main bot worker.
Build the ownership gate
This local example models a generated reply arriving after a person has taken over. It also checks that a reply from the wrong agent can’t pass the human gate.
import assert from 'node:assert/strict';
const conversation = { mode: 'bot', epoch: 1, owner: null };
function proposal(actor, owner = null) {
return { actor, owner, epoch: conversation.epoch, text: 'Prepared answer' };
}
function canSend(p) {
if (p.epoch !== conversation.epoch) return false;
return p.actor === 'bot'
? conversation.mode === 'bot'
: conversation.mode === 'human' && p.owner === conversation.owner;
}
const old = proposal('bot');
conversation.mode = 'human'; conversation.owner = 'agent-a'; conversation.epoch++;
assert.equal(canSend(old), false);
assert.equal(canSend(proposal('human', 'agent-b')), false);
assert.equal(canSend(proposal('human', 'agent-a')), true);
conversation.mode = 'bot'; conversation.owner = null; conversation.epoch++;
assert.equal(canSend(old), false);
assert.equal(canSend(proposal('bot')), true);
console.log('PASS: handoff invalidates old proposals and restricts human owner');
The old bot task is rejected even though its text could be perfectly valid. It no longer has permission to speak in this conversation. When control returns to the bot, the old epoch remains invalid.
The example protects the in-process decision. A production sender needs an atomic claim and durable state, which we’ll add next.
Coordinate the send with a durable claim
Use a send-intent table with (conversation_id, intent_id, owner_epoch, state, provider_message_id). Create or claim the intent only if ownership still matches. Record sending before calling the WhatsApp Cloud API.
The handoff service and sender must use a shared coordination policy. One option is a short per-conversation send lease. A handoff first changes the mode to handoff_pending, preventing new bot claims, then checks existing send intents before giving the agent a clear status.
Don’t keep a database transaction open while waiting for a person or a long network call. Long-held locks create other delays. PostgreSQL documents transaction and row-lock behaviour; choose a narrow critical section for the ownership update. PostgreSQL locking reference.
Explain the send that is already in flight
Once a provider has accepted a message, changing your database row cannot recall it. A send may also have succeeded while its response was lost. That means “handoff complete” needs a precise definition.
The agent inbox can show that automation is stopped but an earlier send is still being resolved. Display the proposal and any provider message ID so the person can see what the customer may receive. Don’t quietly retry an uncertain send after handing control over.
This is the remaining boundary of the design. The version gate stops stale proposals before submission. It doesn’t make the ownership update and an external network side effect one atomic operation.
Return control deliberately
A human conversation shouldn’t revert to the bot because a browser tab closed. Use an explicit release action, or a well-defined lease policy with alerts and a safe fallback.
On release, increment the epoch again. Give the bot a bounded summary of what the agent resolved, plus any current task state. Don’t replay the customer’s earlier complaint as a fresh request.
Keep human permissions distinct from bot permissions. An agent authorised to answer product questions may still lack permission to issue a refund. Conversation ownership decides who can speak; business authorisation decides which actions they can perform.
Test the timing of the handoff
Request handoff before generation, during generation, after a send claim and after provider acceptance. Restart the sender in each uncertain state. Check whether the agent sees an honest status and whether any bot job regains permission accidentally.
Also test delayed jobs from yesterday’s ownership epoch. If the current mode happens to be bot again, those old jobs should still fail the epoch check.
Measure rejected stale proposals and the time from a handoff request to confirmed human ownership. If the dashboard says the handoff is instant but the sender is still active, fix that mismatch before adding more automation.
Start by moving the ownership check into one shared sender. It’s the point every reply has to pass, which makes it the best place to enforce who is allowed to answer.
Run the example and adapt it
Save the code as 04-bot-human-handoff.mjs and run it with Node.js 24 or later:
node 04-bot-human-handoff.mjs
The code above includes assertions you can run locally. It tests the local decision logic with controlled inputs; it doesn’t connect to a live provider or establish production performance. Keep that boundary when adapting it to your application.
