One Customer, Multiple AI Agents: Voice, Chat and App at Once
One customer, multiple AI agents: when voice, chat and app agents act on one account at once, refunds double up and offers collide. How to coordinate them.
One customer, multiple AI agents: when voice, chat and app agents act on one account at once, refunds double up and offers collide. How to coordinate them.
TL;DR: One customer, multiple AI agents: when a customer reaches you on voice, chat and in-app at the same time, three agents can act on the same account inside the same few minutes. Shared conversation history doesn’t stop them from each issuing a refund or each making a retention offer, because history records what was said, not what was committed. Give every channel one commitments record per account, make each remedy a conditional write against it, and have every agent read it before it speaks.
A customer’s order is three days late. They open the in-app chat and complain. The chat agent apologizes and starts looking into it. Two minutes later, still annoyed, they call. Your voice agent picks up, sees a late order and a frustrated customer, and issues a refund. While it’s talking, the chat agent finishes its own look and issues a refund too.
Both agents followed policy. The policy says one refund per incident. The customer got two.
This is a different problem from the one most omnichannel work solves. Teams put a lot of effort into making the customer’s context follow them across channels, so the voice agent knows what was said in chat. That’s conversation memory. What broke here is that two agents committed actions against one account at the same time, and neither saw the other ’s commitment before making its own.
When multiple AI agents serve one customer at once, such as a voice agent, a chat agent and an in-app agent, each can read the same account state and act on it inside the same window. Without coordination, they duplicate refunds, stack retention offers or act on details another channel just changed. Each agent is correct alone; together they break the account’s rules.
The key word is “at once.” A customer who chats on Monday and calls on Thursday is a handoff. The voice agent needs the chat history, and memory solves that. A customer who is in two channels in the same few minutes is contention: two writers, one account, one validity window. That needs coordination.
The scenario below is illustrative, with constructed timings.
t = 0:00 Customer opens in-app chat: order #8812 is three days late
t = 0:40 Chat agent reads order #8812: late, no remedy issued
t = 2:05 Customer calls; voice agent reads order #8812: late, no remedy issued
t = 2:50 Customer, in the app, changes delivery address
t = 3:10 Voice agent issues refund for #8812 and schedules redelivery
to the address it read at 2:05
t = 3:25 Chat agent issues refund for #8812
t = 4:00 Customer says "I want to cancel" in both channels;
voice offers three months at half price, chat offers a free month
Refund issued twice. Both agents read “no remedy issued” before either wrote one. This is the check-then-act shape from Race Conditions Between AI Agents, applied to a remedy instead of a cap.
Two retention offers. Each agent made the offer its playbook allows. The customer now has two in writing and will reasonably expect both.
Address changed mid-call. The voice agent scheduled redelivery against an address it read before the customer changed it in the app. Nothing collided at the database level; the redelivery just went to the wrong place.
It’s tempting to assume the payment system will reject the second refund. Usually it won’t. Stripe’s documentation is explicit: “You can issue more than one refund against a charge, but you can’t refund a total greater than the original charge amount” (Stripe Docs).
That’s the processor enforcing its own invariant: refunds can’t exceed the charge. Your invariant, one goodwill remedy per incident, is a business rule the processor knows nothing about. Two partial refunds that together stay under the charge amount both succeed. The same is true of your CRM, your offer engine and your order system: each enforces its own rules, not the cross-channel rule you care about.
Before building anything, check whether you have the problem. Plenty of large support deployments don’t.
Tenancy is many customers, each on their own state. A million customers each talking to one agent at a time is partitioning. Each account has one writer. You need capacity, not coordination.
Contention is several writers on one customer’s state in one window. It shows up when customers escalate across channels at once, when a proactive outbound agent and an inbound agent reach the same customer, when an AI agent and a human agent both work a case, or when an in-app agent changes account details while a voice agent is acting on them. That’s multi-writer contention.
The litmus test: when two channels act on this account at the same moment, what resolves the conflict today? If the answer is “the second agent usually sees the first one’s note,” you have a race.
You don’t have to guess how often this happens. If your channels log the actions they take, a self-join over the last month tells you:
-- Pairs of actions on the same order, from different channels, within 10 minutes
SELECT a.order_id,
a.channel AS first_channel, a.kind AS first_action,
b.channel AS second_channel, b.kind AS second_action,
b.created_at - a.created_at AS gap
FROM agent_actions a
JOIN agent_actions b
ON b.order_id = a.order_id
AND b.channel <> a.channel
AND b.created_at > a.created_at
AND b.created_at <= a.created_at + interval '10 minutes'
WHERE a.created_at > now() - interval '30 days';
Count the pairs per week, then count the ones where both actions were remedies or offers. That second number is your exposure. If it rounds to zero, conversation memory and ordinary handoffs are enough, and you can stop here. If it doesn’t, it also tells you which channel pairs and which actions to coordinate first.
Use the window that matches your business. Ten minutes catches a customer escalating mid-conversation; a day catches an outbound campaign landing on top of an open case.
Keep one commitments record per account. Every remedy, offer and account-changing action any channel takes is written to one place as a row with a status: pending, issued, declined. Agents write there before they call the refund API or confirm an offer to the customer, not after.
Make “one per incident” a constraint, not a prompt. A partial unique index turns the business rule into something the store enforces for every channel:
CREATE UNIQUE INDEX one_remedy_per_incident
ON remedies (order_id, incident_type)
WHERE status IN ('pending', 'issued');
INSERT INTO remedies (remedy_id, order_id, incident_type, channel, kind, status)
VALUES ('rm-41', 8812, 'late_delivery', 'voice', 'refund', 'pending')
ON CONFLICT (order_id, incident_type) WHERE status IN ('pending', 'issued')
DO NOTHING
RETURNING remedy_id;
-- no row returned: another channel already has a remedy in flight for this incident
In PostgreSQL, a unique index makes attempts to insert a duplicate entry fail (docs), and a WHERE clause makes it a partial unique index that only constrains rows matching the condition, so a declined remedy doesn’t block a new one. With ON CONFLICT DO NOTHING, the second channel’s insert simply returns no row. If the voice and chat inserts race, only one remedy row lands. The losing agent gets a clear answer: a remedy is already in progress, so tell the customer what the other channel did.
Use the same shape for offers. One active retention offer per customer per window is the same constraint on a different table. The second channel learns that an offer exists and can repeat it instead of inventing a new one.
Check versions on details another channel can change. For the address, the redelivery tool should take the address version the agent read and refuse if it changed. Where the address lives in your order system, that check has to happen in the order system or immediately before the call, because a separate store can’t block writes it doesn’t own.
Serialize the commit, not the conversation. Routing every action for one account through a single queue works, but a voice agent can’t hold a customer in silence behind a chat agent’s model call. Let both agents talk freely and serialize only the short step where a remedy is recorded.
Read commitments before speaking. Coordination at write time stops the second refund. It doesn’t stop the voice agent from promising one first. Have each agent read open commitments for the account right before it offers anything, so it says “I see a refund is already being processed” instead of making a promise the write will reject.
Once the remedy row exists, the agent calls the processor with the remedy id as the idempotency key, so a retried call can’t refund twice, then marks the row issued. If the call fails, it marks the row declined, which frees the incident for another attempt. The remedy record coordinates your agents; the processor still executes the refund under its own rules.
A timeout is not a failure. The riskiest moment is the call that never answers. The voice agent sends the refund, the connection drops, and it doesn’t know whether the refund exists. If it treats that as a failure and marks the row declined, the chat agent is free to refund again. Keep the row pending, and retry with the same key. Stripe’s idempotent requests docs describe the contract: “if a connection error occurs, you can safely repeat the request without risk of creating a second object or performing the update twice.” Mark the row declined only when a retry with the same key returns a definite failure.
The pattern above needs one store that every channel’s agent writes commitments to, with real constraints and transactions, and one read that shows each agent, in a single snapshot, the orders, prior remedies, open offers and what the other channels just committed.
The Tacnode Context Lake™ holds that decision context. The commitments record, remedies, offers and agent actions are state Tacnode holds, so recording a remedy or an offer runs as an ACID transaction, and serializable isolation is the default. Two channels recording a remedy for the same incident at the same moment can’t both commit one.
Orders, payments and addresses usually live in your own order system, processor and CRM, and arrive by change data capture. Tacnode enforces nothing inside those systems. It gives each channel’s agent an internally coherent view: the order, the remedies other channels just wrote and derived values such as remedies issued to this customer this quarter, all computed against the same set of ingested events. Each agent’s commitment is written back and becomes context for the next channel’s check, so every channel works from the same shared context.
If your channels never overlap on the same customer within minutes, you don’t need this; conversation memory and an ordinary handoff are enough.

Former Couchbase and IBM. Two decades helping enterprises adopt new database paradigms.
Fresh Context Newsletter
The argument behind the posts — what breaks when AI acts on stale data, from the team building the Context Lake. About twice a month.
Book a demo and discover how Tacnode can power your AI-native applications.
Book a Demo