Beam
Text us

claude agent multiple instances same imessage number

By · Updated

Two Claude Agents, One iMessage Number, No Duplicates

How to stop two Claude agent processes on one Beam iMessage number from both replying to the same text, with a lock pattern and a test you can run today.

Beam is an independent messaging platform, not an Anthropic or Apple product.

Phone message thread illustrating Prevent duplicate agent replies, starting with "Is this item still available?"

Can Two Claude Agents Really Reply to the Same Beam Text?

TL;DR: Beam delivers one signed webhook event per inbound text, but that single delivery does not stop two of your own Claude agent processes from both answering it if neither checks whether the other already claimed that conversation. Give every inbound conversation an ownership key, make one process win an atomic claim on it before calling Claude, and let every other process back off. We walk through the lock, the code, and a test you can run with two terminal windows before trusting it with real contacts.

Yes, and it is not a rare edge case once more than one process can see the same inbound event. We have watched it happen in our own test workspace: run a second copy of a webhook handler while the first is mid conversation, text the shared number once, and both copies answer, worded differently, sometimes contradicting each other.

The failure is not a Beam bug. Beam's inbound path already keeps its own inbox honest: replies arrive on an instant event feed and a bounded reconciliation sweep, and Beam stores one canonical message identity so a contact's text shows up once even though two internal paths observed it. That protects what your team sees inside Beam. It says nothing about what happens after Beam's webhook lands on your endpoint, where you control how many processes read it and what each does next.

Why Does Running More Than One Agent on One Number Create a Race?

Because the deciding step, whether to call Claude and reply, moves from a fixed, single threaded pipeline into however many processes you happen to have running. A single webhook handler, one process, one Beam line, has no race to lose: it reads the event, calls Claude once, sends once, done. Add a second process pointed at the same workspace, for any ordinary reason, and you have a race with no referee unless you build one.

The reasons are mundane, not exotic. Horizontal scaling puts workers behind a queue for throughput, and a redelivered event after a slow acknowledgment reaches a different worker than the one still finishing the first attempt. A deploy overlap brings a new build up before the old one drains its in-flight work, so both are briefly live against the same webhook secret. A resumed session happens when an agent crashes mid reply, a supervisor restarts it, and the restarted session re-reads the same inbound event, not knowing a reply may already be in flight.

None require bad code elsewhere in your stack. They need one missing piece: something that lets only one process claim a conversation before calling Claude, and makes every other one stand down. Anthropic's own issue tracker shows what happens without that piece, in a different context: a GitHub issue on Claude Code describes .claude.json corruption from running five or more Claude Code instances at once, each writing the same shared config file with no file locking between them (anthropics/claude-code#28847). The resource is different, a config file instead of a text thread, but the shape of the bug is the one we are fixing here: more than one process touching one shared thing with no coordination.

What Is a Per-Conversation Ownership Key, and Why Does It Fix This?

Think of a conversation key as a short, stable string naming exactly one thread, built from the two things that define a Beam conversation: the line that received the text and the contact who sent it. Something like line_8841:contact_52390 works, built from the line_id and contact_id the webhook payload already carries. Every process that reads an inbound event derives the same key from the same payload, no coordination needed.

The fix is not the key itself, but what you do with it: before any process may call Claude for that conversation, it has to win an atomic claim on that key. "Atomic" is the word doing the work. Checking "is anyone working on this" and then, in a separate step, writing "now I am" leaves a gap where a second process can read the same "no" and also proceed.

This is the same discipline AI-SMS vendor docs describe on the outbound side, just applied one step earlier. Agentcall's developer docs tell teams to derive a send's idempotency key from the actual work item, for example inv-1042-reminder-1, calling that "the single most valuable line in the send call," since a key built from a timestamp defeats its own purpose on a genuine retry (agentcall.co/docs/agent-sms). The same logic applies here: a conversation key derived from line and contact IDs stays stable across processes and retries in exactly the way a timestamp-based key is not.

How Do You Lock a Conversation Before Calling Claude?

Here is a webhook handler that claims the conversation before it starts a Claude turn, written against a Postgres table rather than a new dependency, since most teams running a Beam integration already have a database:

create table if not exists conversation_claims (
  conversation_key text primary key,
  claimed_by text not null,
  claimed_at timestamptz not null default now(),
  expires_at timestamptz not null
);
import os

WORKER_ID = os.environ["WORKER_ID"]
LOCK_SECONDS = 90  # longer than the slowest realistic Claude turn for this workflow

def claim_conversation(conn, conversation_key: str) -> bool:
    with conn.cursor() as cur:
        cur.execute(
            """
            insert into conversation_claims (conversation_key, claimed_by, expires_at)
            values (%s, %s, now() + interval '%s seconds')
            on conflict (conversation_key) do update
              set claimed_by = excluded.claimed_by, claimed_at = now(), expires_at = excluded.expires_at
              where conversation_claims.expires_at < now()
            returning claimed_by
            """,
            (conversation_key, WORKER_ID, LOCK_SECONDS),
        )
        row = cur.fetchone()
        conn.commit()
        return row is not None and row[0] == WORKER_ID

The on conflict ... where expires_at < now() clause is the part that matters. A fresh key claims cleanly on the insert. One already held by a live claim fails the update's where condition, so nothing changes and returning comes back empty, which the calling code reads as "someone else owns this, stand down." One whose claim already expired, because the holder crashed or ran long, is free for whoever asks next.

Diagram: a sequence diagram with two parallel lanes labeled "Process A" and "Process B," both receiving the same inbound webhook arrow at the same vertical height; Process A's lane shows a green "claim succeeded" box flowing down into "call Claude" then "send reply"; Process B's lane shows a red "claim failed" box at the same height that dead-ends immediately with no further boxes below it.

Where Should the Lock Live: Database, Redis, or In-Process?

An in-process lock, a Python threading.Lock or similar, only coordinates threads inside one running process. It does nothing for a second process, a second server, or a redeployed copy of the same service, so it does not solve the problem this guide is about. It is worth naming only so you do not mistake it for a fix.

Reaching for a database row, as shown above, is usually the right starting point, since the data your handler already needs, the contact, the line, the conversation history, typically lives in a database your service can reach anyway. Redis becomes worth the extra moving part once claim volume makes a relational table's row locking a measurable bottleneck, or once claims need to expire automatically without a background sweep job.

What Changes for a Resumed or Crash Restarted Agent Session?

A resumed Claude Agent SDK session raises the same question from a different angle: not "did two processes see this event," but "did this one process already act on this before it crashed." The fix is the same claim, checked at the same point, just triggered by a restart instead of a second worker. Before resuming or replaying any in-flight turn, the process should attempt to claim the conversation key exactly as a fresh webhook delivery would. If the claim fails because a still-live process already holds it, the restarted session should not resume that turn.

This differs from what our custom send_text tool guide covers. That guide's idempotency key protects one process against its own retries, the same process calling its own tool twice for one intended send. Crashing and restarting means a new process instance picking up where an old one stopped, and the old claim may or may not still be valid depending on timing. Treat a restart as a new claim attempt, never a continuation automatically allowed to proceed.

Single Dispatcher vs Locked Multi-Consumer: Which Pattern Fits?

Most teams land on one of two shapes: either one process is the only thing that ever reads the webhook and calls Claude, making the race structurally impossible, or multiple processes share the work and a claim like the one above decides who proceeds.

Single dispatcher Locked multi-consumer
Prevents a conflicting reply by Having nothing to race, since only one process exists A claim on the conversation key, letting one process proceed
Where the risk moves A single point of failure for every conversation Lock correctness; a bug there reopens the race
Scales to high volume Only by making that one process faster Yes, add consumers and let the claim table absorb it
Fits a resumed session Simply; nothing else was ever competing Needs the restart-claims-again rule above
Operational cost Low: one deployable, no lock table Moderate: claims table, expiry tuning, stuck-claim monitoring

Neither row is the universally right answer. One Claude agent running against one Beam number, with modest volume, usually has no reason to build the claim table; a single dispatcher is simpler and the race cannot occur. The claim pattern earns its complexity once more than one process is actually needed, for throughput, redundancy, or because crash-and-restart is a normal part of the picture.

What Is the Step by Step Procedure to Test This Before Real Contacts?

Run this against a test Beam workspace, never a live client line, before any lock you build gets near a real contact.

  1. Stand up your webhook handler with the claim table above, and set LOCK_SECONDS to a short value you can watch pass in real time, such as twenty.
  2. Start one copy as WORKER_ID=a, text the connected test number once from your own phone, and confirm exactly one reply arrives with WORKER_ID=a logged as the claim winner.
  3. Before that claim window lapses, replay the same webhook payload as WORKER_ID=b and confirm the claim attempt returns false, with no second Claude call or send.
  4. Wait past the claim window, replay the identical payload as WORKER_ID=b again, and confirm the claim now succeeds, since the earlier claim lapsed; this matches a real crash-recovery case, not a test failure.
  5. Start a second live copy as WORKER_ID=c running at the same time as WORKER_ID=a, both against the same test workspace and database, then text the number once more; only one copy should log a successful claim and a sent reply.
  6. Kill whichever process holds the live claim while Claude is still composing a reply, then confirm the other copy claims that same key once the expiry passes without repeating the unfinished reply.
  7. Check the Beam inbox for that test contact directly, not just your own logs, and confirm the thread shows exactly one reply per inbound text, since the inbox is what a contact actually sees.
  8. Only after steps one through seven pass clean, point the same setup at a small slice of real contacts, and watch the claim table for any row still claimed well past its expiry, which usually means a handler is hanging rather than crashing.

Beam is an independent messaging platform, not an Anthropic or Apple product, and the locking pattern above runs in your own service, not inside Beam or Claude. Still deciding whether to build this webhook layer yourself or start from a working iMessage API? Our build your own iMessage API guide covers that setup decision first. Once more than one agent process needs to share request budget as well as conversation ownership, pair this guide with our MCP rate limit guide; deciding between a self hosted MCP server and calling Beam's REST API directly from your claim-checked handler, see our iMessage MCP server guide. For what to do when a webhook delivery never arrives at all, rather than arriving twice, see Beam's missed webhook recovery guide.

Before you connect

What do teams ask before they connect?

Does Beam itself send the same inbound webhook event twice?

Beam's inbound path shows one canonical message once, even when its instant event feed and its reconciliation sweep both observe the same physical text. That protects the inbox view. It does not protect your own code from a second delivery once you add retries, queues, or more than one consumer behind your webhook endpoint, which is a setup decision on your side, not Beam's.

Do we need Redis for this, or can a plain database row work as the lock?

A plain database row works fine, and most teams already have one. A unique constraint on a conversation key, written with an insert that fails loudly on conflict, does the same job as a Redis lock. Redis earns its place once the lock needs to expire automatically or a single Postgres table becomes a bottleneck, not before.

What happens if the lock expires while Claude is still mid turn on a slow tool call?

Another process can then claim the conversation and start its own reply while the first is still working, the exact collision this setup exists to prevent. Set the lock's expiry longer than the slowest realistic Claude turn, and have the handler renew the lock partway through a long run instead of hoping the first attempt finishes.

Does this replace the idempotency key from the send text tool guide?

No, it sits in front of it. The idempotency key in our send text tool guide stops one agent from sending the same message twice on retry. The lock described here stops two separate agent processes from ever both deciding to reply to the same text in the first place. Production needs both: the lock so only one process starts a reply, the key so that process's own retries never send the message twice.

Is this only a problem once we scale to many servers, or can it hit us with two terminal windows open?

It can hit two terminal windows just as easily as two servers. Running a second local copy of the same agent script against the same Beam workspace, even for five minutes while testing a change, puts two processes in front of one inbound webhook with no coordination between them, exactly the setup that produces a crossed reply.

Where do these facts come from?

Checked November 2026.

  1. anthropics/claude-code#28847
  2. agentcall.co/docs/agent-sms

Start with a conversation

What should your agent send first?

Text our team what you are building. We will walk through MCP, webhooks and the CRM path that fits your workflow.

iMessage delivery depends on the recipient device and settings. SMS fallback remains subject to carrier A2P requirements.

Interactive demo · Simulation
CCClearpath Coaching

iMessage
Today 9:41 AM

Hi! I would love to hear how your coaching works.

Happy to chat. Would Thursday work for a discovery call?

Thursday sounds great! Looking forward to meeting you.

Blue bubbles. Read receipts. Typing.

Type your own, or send a suggestion.

LET’S TALK

Want this for your business?

Try a conversation with the Beam team.

We use your number to prepare your text link and follow up. No spam. Privacy.

Beam is not affiliated with Anthropic or Apple.

Powered by Beam

Let's talk

Start a conversation.

Tell us a little about your business, or text our team directly.

Text us

(480) 486-1869