Cookbooks

Slack bot

A Slack Bolt bot that remembers across threads — mapping Slack's user and thread ids onto Memsy actors and sessions.

Slack is where most teams already have their institutional memory, and where it is least retrievable. A bot that answers questions from the team's own history turns that scrollback into something you can ask.

The integration point here is the identity mapping. Slack has no notion of a "session", and a naive port either gives every message its own session (losing all multi-turn context) or shares one session across the whole workspace (blending unrelated conversations). Slack's own threading model is the right boundary, and this recipe uses it.

Note

If you only want Slack content in Memsy — no bot, no code — the Slack connector ingests public channels from the console. This recipe is for the other case: a bot that answers in Slack and records its own conversations.

Install dependencies

pip install memsy slack-bolt openai

Python 3.10 or later.

Create the Slack app

At api.slack.com/apps, create an app, then work through these in order — the scopes have to be in place before you install, because installing is what mints the bot token:

  1. Socket Mode → enable it. No public URL is needed for local development.

  2. OAuth & Permissions → add the bot token scopes app_mentions:read, chat:write, users:read and users:read.email. The first two are all the bot needs to answer a mention; the other two exist only to turn a Slack user id into an email address, which is what this recipe uses to attribute a memory to a person. It still never calls conversations.history, and does not ask for the scope that would let it.

    users:read.email is a sensitive scope in Slack's app-review process. If you would rather not request it, keep the Slack user id as the actor — see the note under Map Slack ids onto Memsy ids.

  3. Event Subscriptions → subscribe to the bot event app_mention.

  4. Install App → Install to Workspace, then approve the permissions prompt. This is its own click and nothing works without it: the bot token does not exist until the app is installed.

Note

A scope added after installing does not take effect until you reinstall. Slack shows a banner on the OAuth page when a reinstall is due — it is easy to read past, and the symptom is a bot that connects fine and then cannot read the channel.

Then collect the two tokens, which live on different pages:

TokenWhereLooks like
Bot tokenOAuth & Permissions, after installingxoxb-…
App-level tokenBasic Information → App-Level Tokens → Generate, with the connections:write scopexapp-…
export SLACK_BOT_TOKEN="xoxb-..."
export SLACK_APP_TOKEN="xapp-..."
export MEMSY_BASE_URL="https://api.memsy.io/v1"
export MEMSY_API_KEY="msy_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export OPENAI_API_KEY="sk-..."

Warning

A bot token can act as the bot in every channel it is invited to. With only app_mentions:read and chat:write that is limited to seeing mentions and posting replies — but keep all four of these in your secret manager, never in the repo.

Map Slack ids onto Memsy ids

This is the whole design decision, so it gets its own module.

# identity.py
from typing import Any

from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

# Slack user id -> email. One lookup per person, not per mention.
_EMAILS: dict[str, str] = {}


def actor_id(client: WebClient, event: dict[str, Any]) -> str:
    """
    Who is speaking, as their email address, so that a memory can be traced
    back to a person without a second lookup.

    Falls back to the Slack user id, which is NOT belt-and-braces: a bot, a
    guest account, or a missing `users:read.email` scope all return a
    profile with no address, and without the fallback every one of those
    turns would be filed under the same empty actor.
    """
    user = event["user"]
    if user not in _EMAILS:
        try:
            profile = client.users_info(user=user)["user"]["profile"]
            _EMAILS[user] = profile.get("email") or f"slack_{user}"
        except SlackApiError:
            _EMAILS[user] = f"slack_{user}"
    return _EMAILS[user]


def session_id(event: dict[str, Any]) -> str:
    """
    Which conversation. `thread_ts` is present on every message inside a
    thread; a top-level message has only `ts`, and becomes the root of the
    thread the bot's reply creates. Falling back to `ts` therefore gives the
    thread and its root message the same session id — which is correct,
    because they are the same conversation.
    """
    return f"slack_{event['channel']}_{event.get('thread_ts', event['ts'])}"
Slack conceptMemsy conceptWhy
Email addressactor_idThe same person across Slack and your other systems, and readable in the console
User id (U…)metadata.slack_user_id, and the actor_id fallbackStable and never reused, so it stays the way to find a person's memories if their address changes
Thread (thread_ts)session_idOne thread is one conversation
Channel (C…)part of session_idKeeps two threads in different channels distinct
Display namenothingUsers rename themselves at will, and nothing links the old name to the new

Warning

An email address is a mutable key, and this is the trade this recipe makes: attribution you can read, in exchange for a key that can change under you. When someone's address changes — a marriage, a rebrand, a domain migration — recall under the new address finds nothing, and nothing errors. The bot simply answers as though it had never met them.

That is why the Slack user id also rides along in metadata. It is immutable, so it remains the way to find the orphaned memories and re-file them. Without it, an address change is unrecoverable.

Note

If you would rather not make that trade, return f"slack_{event['user']}" and drop the two users:* scopes. The id is opaque in the console, but it never moves. Which way to go is a question about your workspace, not about Memsy: actor_id is an arbitrary string that Memsy only ever compares for equality.

Handle the mention

Bolt's default (non-async) App runs handlers on a thread pool, so the synchronous MemsyClient is the right client here — unlike in an async framework such as FastAPI.

# app.py
import json
import logging
import os
import re

from memsy import EventPayload, MemsyClient
from memsy.exceptions import MemsyError
from openai import OpenAI
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

from identity import actor_id, session_id

logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)

memsy = MemsyClient(
    base_url=os.environ["MEMSY_BASE_URL"],
    api_key=os.environ["MEMSY_API_KEY"],
)
openai = OpenAI()
app = App(token=os.environ["SLACK_BOT_TOKEN"])

# "<@U123ABC> what did we decide?" -> "what did we decide?"
MENTION = re.compile(r"<@[A-Z0-9]+>")


@app.event("app_mention")
def handle_mention(event, say, client):
    question = MENTION.sub("", event["text"]).strip()
    if not question:
        say(text="Ask me something.", thread_ts=event.get("thread_ts", event["ts"]))
        return

    actor = actor_id(client, event)
    session = session_id(event)

    # 1. Recall. Scoped to this person — see the note below on team-wide recall.
    try:
        recalled = memsy.search(question, actor_id=actor, limit=5)
        memories = [r.content for r in recalled.results]
    except MemsyError:
        log.exception("memsy recall failed")
        memories = []

    context = "\n".join(f"- {m}" for m in memories) or "(no prior context)"

    # 2. Ground.
    completion = openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    "You are a helpful teammate in Slack. Be brief — two or "
                    "three sentences. Use the prior context below when it is "
                    "relevant.\n\n"
                    f"Prior context:\n{context}"
                ),
            },
            {"role": "user", "content": question},
        ],
    )
    answer = completion.choices[0].message.content or ""

    # Reply in-thread so the conversation stays one session.
    say(text=answer, thread_ts=event.get("thread_ts", event["ts"]))

    # 3. Record.
    try:
        memsy.ingest(
            [
                EventPayload(
                    actor_id=actor,
                    session_id=session,
                    kind="user_message",
                    content=question,
                    metadata=json.dumps({"slack_user_id": event["user"]}),
                ),
                EventPayload(
                    actor_id=actor,
                    session_id=session,
                    kind="assistant_message",
                    content=answer,
                    metadata=json.dumps({"slack_user_id": event["user"]}),
                ),
            ]
        )
    except MemsyError:
        # The reply is already in the channel; a failed write must not
        # produce a second, confusing error message in the thread.
        log.exception("memsy ingest failed")


if __name__ == "__main__":
    SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()

Run it

python app.py

Note

App(token=...) calls auth.test against Slack while it constructs, so start-up needs network access and a valid bot token — an invalid one fails at import, before any of your own code runs. To build offline, pass token_verification_enabled=False; leave it on in production, where failing at boot beats failing on the first mention.

Tip

No threshold here, so recall uses the default 0.0 and returns the top 5 however weakly they match. Raising it cuts noise, but the useful value depends on your tier — free-tier scores cluster in 0.0–0.1, so a floor of 0.3 copied from a reranked-tier example filters out everything. See Searching memory.

Invite the bot to a channel and mention it. Reply in the same thread and it carries the context; start a new thread and it starts a new session — but still recalls what you told it in the old one, because recall is scoped to the actor, not the session.

Optional: recall across the whole team

Per-actor recall means the bot remembers you. Often what a team actually wants is for it to remember the team — so a question about a decision surfaces the answer whoever made it.

Tag events with a team at ingest time:

EventPayload(
    actor_id=actor,
    session_id=session,
    kind="user_message",
    content=question,
    team_id=f"slack_{event['channel']}",   # the channel is the team
)

Then pass the team on recall, which widens results to include team-promoted memories:

recalled = memsy.search(
    question,
    actor_id=actor,
    team_ids=[f"slack_{event['channel']}"],
    limit=5,
)

Warning

This makes one person's messages reachable by everyone else in the channel. That is the point in a shared channel, and a privacy incident in a DM. Gate it on event["channel_type"] if your bot handles both.

Take it to production

python app.py is a foreground process holding a websocket open. That does not change in production — what changes is who keeps it running.

Socket Mode is a client, not a server. It dials out to Slack and holds the connection, so it belongs wherever long-running processes belong: an ECS service, a Kubernetes Deployment, Fly, Railway, a systemd unit. One container, one command, restarted when it dies.

Warning

It cannot run on Lambda, Vercel Functions, or anything that scales to zero. There is no inbound request to wake it, and a function that returns closes the socket. This is the constraint teams tend to find after choosing the platform.

Otherwise there is little to deploy. The recipe holds no state but the email cache in identity.py, and that is a cache — a second replica simply warms its own. What is left is the four environment variables, which belong in your secret manager rather than the image.

Slack allows an app several concurrent Socket Mode connections and delivers each event to exactly one of them, so two replicas let a rolling restart overlap without dropping a mention. Check the current connection limit before depending on it.

If it has to be serverless

Then it stops being a Socket Mode client and becomes a webhook. Bolt ships the adapters — slack_bolt.adapter.fastapi, .flask, .aws_lambda, .google_cloud_functions.

Add signing_secret=os.environ["SLACK_SIGNING_SECRET"] to the App(...) in app.py, since over HTTP the signature is the only thing proving Slack sent the request, and serve the same app:

# serve.py -- an HTTP endpoint in place of SocketModeHandler
from fastapi import FastAPI, Request
from slack_bolt.adapter.fastapi import SlackRequestHandler

from app import app  # the same App, the same @app.event("app_mention")

api = FastAPI()
handler = SlackRequestHandler(app)


@api.post("/slack/events")
async def slack_events(request: Request):
    return await handler.handle(request)

Set that URL as the Request URL under Event Subscriptions, and turn Socket Mode off. Three things Socket Mode was doing become yours:

Socket ModeHTTP
Proving the caller is Slackthe socket itselfsigning_secret, verified on every request
Reachabilitynone neededa public HTTPS endpoint
Setup handshakenoneSlack's url_verification challenge, which the adapters answer for you

Warning

So does the three-second ack. Bolt acks for you in Socket Mode, and on a long-running HTTP server it still does — it replies first and runs your handler on a thread. On a function platform you need process_before_response=True, which means the handler must finish before the response goes back, and a recall plus a completion plus an ingest will not reliably fit in three seconds. Slack then retries, and you get a duplicate reply and a duplicate pair of memories.

The serverless shape is therefore not this file on a different host. It is: ack immediately, put the turn on a queue, and answer from a worker — at which point you are running a long-lived process again, just a second one.

What to watch for

  • Replying outside the thread. Omitting thread_ts on say() posts to the channel root, which starts a new Slack thread — and the next message arrives with a different session_id than the one you just wrote.
  • Slack's 3-second ack. Bolt acknowledges the event for you before your handler runs, so a slow LLM call will not cause Slack to retry. If you move to a custom HTTP adapter, ack first and do the work after.
  • Bot messages echoing back. app_mention only fires on mentions, so the bot does not hear itself. If you widen to message events, filter on event.get("bot_id") or you will ingest an infinite loop of the bot's own replies.
  • A missing users:read.email scope. The lookup does not raise, it returns a profile with no email, so every turn quietly files itself under the Slack id fallback instead. If the console shows slack_U… actors where you expected addresses, the scope is missing or the app was never reinstalled after it was added.
  • An address that changes. Recall under the new address finds nothing and nothing errors — the bot answers as though it had never met that person. metadata.slack_user_id is how you find what was orphaned; see the trade under Map Slack ids onto Memsy ids.
  • Display names as ids. Covered above, and worth repeating: it fails silently.

Complete code

Two files, verbatim from the steps above. The optional team-wide recall from the last step is left out — add it only once you have decided that a shared channel should share memories.

identity.py
# identity.py
from typing import Any

from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

# Slack user id -> email. One lookup per person, not per mention.
_EMAILS: dict[str, str] = {}


def actor_id(client: WebClient, event: dict[str, Any]) -> str:
    """
    Who is speaking, as their email address, so that a memory can be traced
    back to a person without a second lookup.

    Falls back to the Slack user id, which is NOT belt-and-braces: a bot, a
    guest account, or a missing `users:read.email` scope all return a
    profile with no address, and without the fallback every one of those
    turns would be filed under the same empty actor.
    """
    user = event["user"]
    if user not in _EMAILS:
        try:
            profile = client.users_info(user=user)["user"]["profile"]
            _EMAILS[user] = profile.get("email") or f"slack_{user}"
        except SlackApiError:
            _EMAILS[user] = f"slack_{user}"
    return _EMAILS[user]


def session_id(event: dict[str, Any]) -> str:
    """
    Which conversation. `thread_ts` is present on every message inside a
    thread; a top-level message has only `ts`, and becomes the root of the
    thread the bot's reply creates. Falling back to `ts` therefore gives the
    thread and its root message the same session id — which is correct,
    because they are the same conversation.
    """
    return f"slack_{event['channel']}_{event.get('thread_ts', event['ts'])}"

Run it:

pip install memsy slack-bolt openai
export SLACK_BOT_TOKEN="xoxb-..."
export SLACK_APP_TOKEN="xapp-..."
export MEMSY_BASE_URL="https://api.memsy.io/v1"
export MEMSY_API_KEY="msy_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export OPENAI_API_KEY="sk-..."

python app.py

Next steps