Back to Blog
Smartphone home screen showing a row of messaging and chat application icons

Building Slack AI Bots Without Duplicate or Stale Replies

10 min read

A Slack AI bot can understand a mention and still reply twice. The first request waits several seconds for a model. Slack does not receive an acknowledgement in time, so it sends the event again. Both workers finish and post. Edits, bot-generated messages, and new thread replies cause similar failures when the application treats every callback as a new task.

The fix belongs in the event controller, not the prompt. A reliable bot acknowledges before inference, records one durable job per Slack event, rebuilds current thread state, rejects stale work, and claims one reply before posting. This guide turns Slack's separate event, message, thread, and acknowledgement rules into one implementation sequence.

Why the request handler must stay small

Slack's Events API documentation requires an HTTP success response within three seconds. When delivery fails, Slack retries three times with exponential backoff. The same page recommends returning 200 OK quickly and moving processing to a queue instead of reacting inside the request process.

A model call does not fit that deadline reliably. Retrieval, policy checks, and tool calls make the timing less predictable. Even a usually fast handler can cross three seconds during network congestion. A Bolt for Python issue about delayed acknowledgement reports repeated events when ack() took about three seconds because of network latency. That is a practitioner report, not a general Slack guarantee, but the failure matches the documented retry contract.

The inbound handler should perform only work needed to accept or reject delivery:

  1. Verify the Slack signature and timestamp.
  2. Parse the outer event envelope.
  3. Insert the event into durable storage using event_id as a unique key.
  4. Enqueue processing only when the insert is new.
  5. Return success immediately.

Do not fetch thread history, call the model, or post a message before acknowledging. A worker can perform those actions and retry them without causing Slack to redeliver the original event.

Store delivery identity separately from message identity

Slack's event envelope and message payload describe different objects. The Events API defines event_id as globally unique. The Slack message event reference identifies a message with its channel and per-channel timestamp, and it also describes edits and subtypes. A thread uses the parent message timestamp as its root identity.

Keep those identities in separate records:

Record Identity Purpose
Event delivery event_id Deduplicate Slack callbacks and retries
Message workspace, channel, ts Track one Slack message and later changes
Thread workspace, channel, root thread_ts Reconstruct conversation state
Decision event ID, thread version Bind model output to the context it read
Reply intent event ID, action kind Permit one outbound reply

One message can create more than one event. An edit can produce a new event ID for an existing message, while a retry can repeat the same event ID. A bot message is new but may be ineligible as input. One generic deduplication key cannot distinguish these cases.

Use a database uniqueness constraint for event_id; an in-memory set is not enough. Multiple application instances may receive retries, and a restart clears process memory. The insert and job creation should commit together or use an outbox. If the process crashes after saving the event but before publishing the job, a recovery scanner can enqueue saved events that have no job.

Filter events before model work

Message events include more than new human text. Slack documents message subtypes, edits, and bot messages on the message event surface. A worker should classify the event shape before paying for inference.

Reject or route events with deterministic rules:

  • Ignore messages produced by the bot's own user or bot ID.
  • Ignore bot_message unless another approved bot is an intentional source.
  • Treat message edits as state changes, not automatically as new questions.
  • Treat deletions as invalidation signals for pending decisions.
  • Ignore events without usable text unless the workflow explicitly handles files or blocks.
  • Require a mention, direct message, approved channel, or another clear trigger.

The self-message rule prevents a feedback loop in which the bot receives its own answer and asks the model to answer again. Check trusted Slack identifiers, not a text prefix such as "bot:". Text can be copied or changed; the sender identity is the control.

An edit policy needs more care. If a user edits a triggering message before processing starts, process the current text and record the edited timestamp. If the edit arrives after a reply has been sent, do not silently post a second answer. Record that the answered source changed and either offer a deliberate rerun action or send it to an operator based on the product policy.

Rebuild the current thread before inference

The event payload is a trigger, not a complete conversation snapshot. For a thread reply, identify the root from thread_ts. For a top-level mention, use the message ts as the new root. Then load the current thread from Slack or from a synchronized local store before building model context.

The chat.postMessage reference says to provide the parent message's ts as thread_ts when posting a reply. It warns against using a reply timestamp instead of the parent. Store that root explicitly so an adapter cannot accidentally create a top-level answer or attach the response under the wrong child message.

Assign the reconstructed thread a version. A content hash over ordered message identities, edit timestamps, and relevant text works when the thread is small. A monotonic version in local storage works when all updates flow through one synchronizer. The exact mechanism matters less than the comparison: the worker must prove that the context used for inference is still current before it posts.

A practical context record contains:

thread_context = {
  workspace_id,
  channel_id,
  root_ts,
  source_message_ts,
  source_event_id,
  ordered_messages,
  version,
  captured_at
}

Remove messages the requesting user is not allowed to expose to the model. Preserve speaker identity and timestamps so the model can distinguish a new request from an older answer. Limit context with a deterministic policy, such as the root plus the most recent relevant replies, rather than dropping arbitrary messages when token limits are reached.

Bind the model decision to a thread version

The model should produce a proposal with the source event ID, root timestamp, captured thread version, answer, and action type. Keep those binding fields outside model control when possible. Application code already knows them and can attach them after generation.

Before creating a reply intent, reconstruct or refresh the thread version. Handle changes by type:

  • A reaction or read-state change does not invalidate the answer.
  • An unrelated bot status message can be ignored if policy says it carries no conversation content.
  • A new human message invalidates the proposal because the question or state may have changed.
  • An edit to any message included in context invalidates the proposal.
  • A deletion of the source message cancels the proposal.
  • An existing bot reply for the same event completes the work without another post.

Acknowledgement and event deduplication do not prevent stale replies. A unique event can become obsolete while the model runs. The version comparison decides whether its answer remains valid.

For long model calls, do not lock the thread row throughout inference. Read a version, perform inference without a database lock, then compare the version in a short transaction. Holding a lock during an external call blocks unrelated updates and still cannot stop Slack users from changing the actual thread.

Claim one outbound reply before posting

Create a durable reply intent before calling Slack. A useful unique key is (workspace_id, source_event_id, action_kind). Store the target channel, root timestamp, approved body hash, state, attempt time, and returned Slack message timestamp.

The worker flow can look like this:

process(event_id):
    event = load_event(event_id)
    if not eligible_message(event):
        mark_ignored(event_id)
        return

    context = load_current_thread(event)
    proposal = run_model(context)

    if current_thread_version(context.root) != context.version:
        mark_stale(event_id)
        enqueue_reconsideration(context.root)
        return

    intent = insert_reply_intent_once(event_id, context.root, proposal)
    if intent.already_sent or intent.claimed_elsewhere:
        return

    post_reply(intent)

The unique insert prevents two workers handling one retried event from both posting. It also gives an operator a record of what the application meant to do before the external side effect.

Slack's posting method documents response errors and special rate limits. Treat 429 as a scheduled retry using the provider's retry guidance, not an immediate loop. Serialize or budget posts by channel when a busy bot can produce bursts. A reply intent remains pending while it waits for capacity; it does not need another model call.

Handle an uncertain post without guessing

A network timeout can occur after Slack accepts a message but before the worker receives the response. Retrying immediately may create a duplicate. Mark the intent outcome as unknown and reconcile before posting again.

Reconciliation should inspect the target thread for a bot message that matches strong evidence: the expected bot identity, root timestamp, creation window, and approved body hash. If the original call returned a Slack timestamp before a later local failure, use that identifier first. When the evidence matches, record the existing message as the completed intent. If the result remains ambiguous, hold the intent for review rather than sending a second answer.

Do not claim that event_id makes chat.postMessage idempotent. It deduplicates inbound delivery. The outbound claim is an application control, and uncertain-result reconciliation is still required when the posting response is lost.

Test the boundaries that create duplicates

A successful mention proves that credentials and model access work. Reliability tests must force retries and state changes between durable steps.

First, deliver the same event envelope twice to separate application instances. Both requests should return promptly, storage should contain one event record, and the queue should contain one logical job. Repeat after restarting both instances to prove deduplication is durable.

Next, delay the worker beyond three seconds while keeping the receiver fast. Simulate Slack's retry of the original event. The receiver should acknowledge both attempts, but only one model call and one reply intent should run.

Test bot loops by feeding the bot's own posted message back through the message-event handler. It must be recorded or ignored according to audit policy without producing another inference job. Add a message_changed event whose content is unchanged, then one whose text changed. Only the changed content should invalidate pending work.

Test stale context by adding a human thread reply after model inference starts. The version check must reject the old proposal. The reconsideration job should rebuild context and decide whether another reply is still needed rather than automatically posting the first answer.

Finally, simulate Slack accepting a post while the client times out. The intent should move to unknown, reconciliation should find the existing bot message, and no second post should occur. Run the same test with no matching message and verify that the system pauses or retries only after the configured evidence threshold is met.

Track event acknowledgement latency, duplicate event inserts, ignored subtype counts, model jobs per accepted event, stale proposal counts, pending reply intents, unknown post outcomes, reconciled posts, and duplicate-reply incidents. These metrics correspond to the state machine and make a rising retry or stale-work rate visible before users complain.

Put the controller in place before tuning the prompt

Start with the fast authenticated receiver and a unique event table. Add subtype and self-message filters, then reconstruct threads and assign versions. Bind each model proposal to the captured version. Last, add reply intents, channel-aware scheduling, and unknown-post reconciliation.

Run duplicate delivery, delayed processing, self-message, thread-change, and accepted-but-timed-out tests against one test workspace. Prompt changes can wait until those pass. A better answer is not useful if the bot posts it twice or sends it after the conversation has moved on.

References


About Fire In Belly: Independent senior engineering from Tallinn, Estonia. We design and build AI workflow automation with the event controllers, thread version checks, and reply claims described above, at published fixed prices. Schedule a call to discuss your next project.