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 openaiPython 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:
-
Socket Mode → enable it. No public URL is needed for local development.
-
OAuth & Permissions → add the bot token scopes
app_mentions:read,chat:write,users:readandusers: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 callsconversations.history, and does not ask for the scope that would let it.users:read.emailis 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. -
Event Subscriptions → subscribe to the bot event
app_mention. -
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:
| Token | Where | Looks like |
|---|---|---|
| Bot token | OAuth & Permissions, after installing | xoxb-… |
| App-level token | Basic Information → App-Level Tokens → Generate, with the connections:write scope | xapp-… |
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 concept | Memsy concept | Why |
|---|---|---|
| Email address | actor_id | The same person across Slack and your other systems, and readable in the console |
User id (U…) | metadata.slack_user_id, and the actor_id fallback | Stable and never reused, so it stays the way to find a person's memories if their address changes |
Thread (thread_ts) | session_id | One thread is one conversation |
Channel (C…) | part of session_id | Keeps two threads in different channels distinct |
| Display name | nothing | Users 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.pyNote
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 Mode | HTTP | |
|---|---|---|
| Proving the caller is Slack | the socket itself | signing_secret, verified on every request |
| Reachability | none needed | a public HTTPS endpoint |
| Setup handshake | none | Slack'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_tsonsay()posts to the channel root, which starts a new Slack thread — and the next message arrives with a differentsession_idthan 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_mentiononly fires on mentions, so the bot does not hear itself. If you widen tomessageevents, filter onevent.get("bot_id")or you will ingest an infinite loop of the bot's own replies. - A missing
users:read.emailscope. The lookup does not raise, it returns a profile with noemail, so every turn quietly files itself under the Slack id fallback instead. If the console showsslack_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_idis 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
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.pyNext steps
- Actors and sessions — the full model behind this mapping.
- Slack connector — ingest channel history without writing a bot.
- Ingesting events — batching, event kinds, and metadata.

