apiclawnotes

Putting an LLM Behind a Discord or Slack Bot: The 3-Second Rule, Per-User Limits and Thread Memory

A common first LLM bot for Slack answers every question twice. A common first one for Discord says "This interaction failed" half the time. Both problems have the same cause, and it had nothing to do with the model: chat platforms expect a fast acknowledgment, and an LLM call isn't fast.

This post covers the patterns I now use for any Discord or Slack bot that calls a model. The code uses the OpenAI SDK against an OpenAI-compatible base URL. My examples point at APIClaw, a gateway I build, so weigh that accordingly. The patterns work the same with OpenAI directly, a local vLLM or Ollama server, or any other compatible endpoint.

The 3-second rule

Both platforms give you about three seconds:

  • Discord requires an initial response to an interaction (slash command, button) within 3 seconds. If you miss it, the user sees "This interaction failed". If you defer, you get a token that stays valid for 15 minutes, which is plenty of time for a follow-up message.
  • Slack expects your app to acknowledge an Events API request within 3 seconds. If it doesn't, Slack retries the delivery, up to three times, and adds an X-Slack-Retry-Num header. That's where duplicate answers come from: the first call is still running when the retry arrives and starts a second one.

A model call that streams a long answer can easily take 10 to 60 seconds. So the rule is: acknowledge first, think second.

Discord: defer, then follow up

With discord.py and slash commands:

import os, discord
from discord import app_commands
from openai import AsyncOpenAI

llm = AsyncOpenAI(base_url="https://apiclaw.biz/v1",
                  api_key=os.environ["LLM_API_KEY"], timeout=90)
MODEL = "deepseek-v4-flash"

intents = discord.Intents.default()
bot = discord.Client(intents=intents)
tree = app_commands.CommandTree(bot)

@tree.command(name="ask", description="Ask the assistant")
async def ask(interaction: discord.Interaction, question: str):
    await interaction.response.defer(thinking=True)   # within 3 seconds
    r = await llm.chat.completions.create(
        model=MODEL, max_tokens=800,
        messages=[{"role": "system", "content": "Answer briefly. Use plain text."},
                  {"role": "user", "content": question}])
    text = r.choices[0].message.content or "(no answer)"
    for i in range(0, len(text), 1900):                # Discord caps messages at 2,000 chars
        await interaction.followup.send(text[i:i + 1900])

@bot.event
async def on_ready():
    await tree.sync()

bot.run(os.environ["DISCORD_TOKEN"])

Three details matter here:

  • Use AsyncOpenAI, not the sync client. discord.py runs on one asyncio event loop. A blocking HTTP call freezes the whole bot, including heartbeats, and under load Discord will treat the connection as dead.
  • Slash commands avoid the Message Content intent. If your bot reads every message to watch for mentions, you need a privileged intent, and verified bots have to justify it. A /ask command doesn't need it.
  • Split long answers. Messages over 2,000 characters are rejected, not truncated.

Slack: ack immediately, work after the ack

With Bolt for Python, slash commands give you an explicit ack(), and you call it before doing anything slow. For events like app_mention, Bolt acknowledges for you, and on a normal long-running process it runs your listener in a worker thread after the ack goes out. So a plain listener is fine there:

import os
from slack_bolt import App
from openai import OpenAI

app = App(token=os.environ["SLACK_BOT_TOKEN"],
          signing_secret=os.environ["SLACK_SIGNING_SECRET"])
llm = OpenAI(base_url="https://apiclaw.biz/v1",
             api_key=os.environ["LLM_API_KEY"], timeout=90)
seen = set()

@app.event("app_mention")
def on_mention(event, client):
    key = (event["channel"], event["ts"])
    if event.get("bot_id") or key in seen:
        return
    seen.add(key)
    r = llm.chat.completions.create(model="deepseek-v4-flash", max_tokens=800,
        messages=[{"role": "user", "content": event["text"]}])
    client.chat_postMessage(channel=event["channel"],
                            thread_ts=event.get("thread_ts", event["ts"]),
                            text=r.choices[0].message.content)

@app.command("/ask")
def ask(ack, command, respond):
    ack("Thinking...")                 # within 3 seconds
    r = llm.chat.completions.create(model="deepseek-v4-flash", max_tokens=800,
        messages=[{"role": "user", "content": command["text"]}])
    respond(r.choices[0].message.content)

The seen set, keyed by channel and message timestamp, is a cheap dedupe for Slack retries. In production I'd put it in Redis with a TTL so it survives restarts and works across instances. Serverless is the exception: there you set process_before_response=True, and the work after the ack can be killed. Bolt's lazy listeners exist for exactly this case, so use them.

Also ignore messages from bots, including your own. Two AI bots in one channel that both answer every message will burn through a quota in minutes.

Streaming into a message without hitting rate limits

Streaming feels much better in chat: post a placeholder, then edit it as tokens arrive. Don't edit on every token. Both platforms rate-limit message edits, and a fast model can produce dozens of tokens per second. I buffer and edit at most once every 1.5 seconds:

import time

async def stream_to_message(msg, messages):
    buf, last = "", 0.0
    stream = await llm.chat.completions.create(model=MODEL, messages=messages,
                                               max_tokens=800, stream=True)
    async for chunk in stream:
        if chunk.choices and chunk.choices[0].delta.content:
            buf += chunk.choices[0].delta.content
            if time.monotonic() - last > 1.5:
                await msg.edit(content=buf[:1900] + " ▌")
                last = time.monotonic()
    await msg.edit(content=buf[:1900] or "(no answer)")

The if chunk.choices guard matters: some endpoints send a final chunk with an empty choices list, for example when usage reporting is turned on.

Thread memory without unbounded prompts

Users expect the bot to remember the conversation. The simplest reliable approach:

  • Key memory by thread, not by user. In Slack, use thread_ts. In Discord, use the thread or channel ID. Each conversation then has its own context, and two people in one channel don't share a confused history.
  • Rebuild from the platform when you can. Slack's conversations.replies and Discord's channel history give you the thread itself, so you don't need your own database for a small bot.
  • Trim by count, then by size. Keep the system prompt, plus the last 10 to 20 turns, plus a character budget. Drop the oldest turns first. Long histories make every call slower, and on per-token APIs, more expensive.

Per-user limits and quota math

A public bot is an open door to your API key. Before you invite it anywhere:

  • Rate-limit per user. Something like 10 requests per user per 10 minutes stops one person from draining the whole budget. A dictionary of timestamps is enough for one process.
  • Pick the model for the job. Chat answers rarely need the heaviest model. On my gateway, plans are priced by requests per day, and the public model hub gives each model a weight: deepseek-v4-flash currently counts as 1 request, kimi-k3 as 2 and claude-opus-5-5 as 3. On a busy server that's the difference between a quota that lasts all day and one that runs out by lunch. Per-token providers have the same tradeoff, just measured in money.
  • Fail politely. When the daily quota is used up (mine resets at 00:00 UTC) or the endpoint returns a 429 or 5xx, reply with a short "I'm out of capacity, try again later" instead of a stack trace or silence. Catch openai.APIStatusError and openai.APITimeoutError around every call.
  • Keep secrets in the environment. The bot token and the LLM key both go in env vars or a secret manager, never in the repo. Use a separate LLM key for the bot so you can revoke it without touching anything else.

Checklist before you invite it

  • Acknowledges within 3 seconds (defer or ack), does the work afterward
  • Dedupes Slack retries and ignores bot messages
  • Uses an async client on Discord
  • Splits or truncates long replies
  • Throttles streaming edits
  • Has per-user limits and a polite out-of-capacity message
  • Uses model IDs copied from the endpoint's model list, not typed from memory

None of this is complicated, but each item is an easy bug to ship. Get the acknowledgment right first. Most "the AI bot is broken" reports trace back to that three-second window.