# plan-for-slackbot-architecture
Awesome — you’re describing exactly the right approach: **treat Slack channel threads as multi-party conversations you own**, then “rehydrate” the right slice into each Responses API call.
Below is a compact, production-friendly pattern you can drop into your Slack bot. I’ll show:
1. a minimal schema,
2. the context builder (window + summarization),
3. the Slack → DB → OpenAI flow, and
4. a few reliability notes (idempotency, pagination, FERPA-ish hygiene).
<iframe width="560" height="315" src="https://www.youtube.com/embed/8_GgeASwHwQ?si=EgyMIlX64mQGfJxp" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
---
# 1) Minimal schema (Postgres or SQLite)
```sql
-- Conversations are Slack threads
CREATE TABLE conversations (
id TEXT PRIMARY KEY, -- e.g., `${channel_id}:${thread_ts}`
channel_id TEXT NOT NULL,
thread_ts TEXT NOT NULL,
system_instructions TEXT, -- per-thread instructions (optional)
summary TEXT DEFAULT '', -- rolling summary of earlier turns
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Who is speaking (multi-user channel with roles)
CREATE TABLE participants (
id TEXT PRIMARY KEY, -- `${conversation_id}:${slack_user_id}`
conversation_id TEXT NOT NULL,
slack_user_id TEXT NOT NULL,
role TEXT NOT NULL, -- 'student' | 'tf' | 'prof' | 'bot' | etc.
display_name TEXT,
UNIQUE(conversation_id, slack_user_id)
);
-- Every message (Slack human, the bot, tools)
CREATE TABLE messages (
id TEXT PRIMARY KEY, -- ULID/UUID
conversation_id TEXT NOT NULL,
role TEXT NOT NULL, -- 'user' | 'assistant' | 'system' | 'tool'
actor_id TEXT, -- slack_user_id or 'bot'
source TEXT NOT NULL, -- 'slack' | 'bot' | 'system'
content TEXT NOT NULL,
response_id TEXT, -- OpenAI Responses API response.id (when assistant)
previous_response_id TEXT, -- for chaining (optional safety)
slack_ts TEXT, -- original Slack ts (for idempotency)
token_count INT, -- cache from tokenizer (optional)
meta JSONB, -- e.g., attachments, files, grading rubric, etc.
created_at TIMESTAMP DEFAULT NOW()
);
-- Optional: long-term, per-person memory
CREATE TABLE user_memories (
slack_user_id TEXT PRIMARY KEY,
notes TEXT, -- short bullet memory for rehydration
updated_at TIMESTAMP DEFAULT NOW()
);
```
Keying the conversation as `channel_id:thread_ts` keeps things simple. Store a **rolling summary** on the conversation to keep prompts small.
---
# 2) Context builder (sliding window + summary)
**Goal:** fit within a token budget while preserving who said what (roles) and the latest turns.
Algorithm (fast + reliable):
1. Load `conversation.summary`.
2. Pull **most recent messages** (e.g., the last 30) for that conversation ordered by time.
3. If the window exceeds your token budget, **compress older chunk** into the conversation summary (overwrite `conversations.summary`).
4. Build the prompt:
* `system`: global bot policy + per-course rules + `conversation.system_instructions`.
* `context`: injected **summary** (“Earlier in this thread…”) + **role map** (“Participants include: @u1 (student), @u2 (TF), @u3 (prof)”).
* **recent messages** verbatim (last N that fit).
* Optionally attach **per-speaker memory** snippets (from `user_memories`) only when that speaker appears in the recent window.
Token budgeting rule of thumb:
* Budget = 70% input, 30% output (adjust to taste).
* Fall back to *summary-only + last 6–10 turns* if you’re still over.
---
# 3) Slack → DB → OpenAI (Node, Bolt, Responses API)
> Below is intentionally compact but complete. Replace your storage layer as needed.
```js
// deps
require('dotenv').config();
const { App } = require('@slack/bolt');
const { OpenAI } = require('openai');
// …your db client (knex, drizzle, prisma). We'll pseudo-code db* helpers.
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const slack = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
socketMode: true,
appToken: process.env.SLACK_APP_TOKEN,
});
// ---------- Helpers ----------
function convoId(channel, threadTs) { return `${channel}:${threadTs || 'root'}`; }
async function upsertConversation(channel, threadTs) {
const id = convoId(channel, threadTs);
const c = await dbGetConversation(id);
if (c) return c;
return dbCreateConversation({ id, channel_id: channel, thread_ts: threadTs || 'root' });
}
async function ensureParticipant(conversationId, slackUserId, roleGuess, displayName) {
const id = `${conversationId}:${slackUserId}`;
const p = await dbGetParticipant(id);
if (p) return p;
return dbCreateParticipant({ id, conversation_id: conversationId, slack_user_id: slackUserId, role: roleGuess, display_name: displayName });
}
// Heuristic role guesser (you can replace with real roster)
function guessRole(user) {
// Example: treat channel owners or a hardcoded list as 'prof'/'tf'
return 'student';
}
async function buildContext(conversationId, maxInputTokens = 6000) {
const convo = await dbGetConversation(conversationId);
const recent = await dbListRecentMessages(conversationId, /* limit */ 50);
const participants = await dbListParticipants(conversationId);
const roleMap = participants.map(p => `• <@${p.slack_user_id}> — ${p.role}`).join('\n');
let system = [
`You are an instructional assistant in a university course Slack.`,
`Be precise, cite sources when appropriate, and differentiate help for students vs TFs vs professor.`,
convo.system_instructions || ''
].filter(Boolean).join('\n');
// Assemble body with summary and recent messages:
const header = [
`Conversation participants:\n${roleMap}`,
convo.summary ? `Earlier thread summary:\n${convo.summary}` : null
].filter(Boolean).join('\n\n');
// Convert DB messages -> Responses API "input" format
const input = [{ role: 'system', content: [{ type: 'text', text: system }] }];
input.push({ role: 'user', content: [{ type: 'text', text: header }] });
for (const m of recent) {
const role = m.role; // 'user' | 'assistant' | 'tool' (system already injected)
input.push({
role,
content: [{ type: 'text', text: `<@${m.actor_id || 'bot'}>: ${m.content}` }]
});
}
// (Optional) Trim by tokens here using your tokenizer + re-summarize older messages if needed.
return { input };
}
async function summarizeAndTrim(conversationId) {
// Summarize older messages to keep window light.
const oldestChunk = await dbListOldMessagesForCompression(conversationId);
if (!oldestChunk.length) return;
const text = oldestChunk.map(m => `${m.role === 'assistant' ? 'Assistant' : `<@${m.actor_id}>`}: ${m.content}`).join('\n');
const summaryResp = await openai.responses.create({
model: 'gpt-5.1-mini', // cheap, fast
input: [
{ role: 'system', content: [{ type: 'text', text: 'Summarize for instructional continuity. Keep roles and key decisions/actions.' }]},
{ role: 'user', content: [{ type: 'text', text }] }
]
});
const newSummary = summaryResp.output_text;
await dbAppendToConversationSummary(conversationId, `\n\n[Compressed ${oldestChunk.length} turns]\n${newSummary}`);
await dbArchiveCompressedMessages(oldestChunk.map(m => m.id));
}
// ---------- Slack handler ----------
slack.event('message', async ({ event, client }) => {
// Ignore bot echoes without text
if (!event.text || event.subtype === 'bot_message') return;
const channel = event.channel;
const threadTs = event.thread_ts || event.ts; // treat root message as start of convo
const convId = (await upsertConversation(channel, threadTs)).id;
// Record the human message
await dbCreateMessage({
id: makeId(),
conversation_id: convId,
role: 'user',
actor_id: event.user,
source: 'slack',
content: event.text,
slack_ts: event.ts
});
// Ensure participant exists
const userInfo = await client.users.info({ user: event.user });
await ensureParticipant(convId, event.user, guessRole(userInfo.user), userInfo.user.profile.display_name || userInfo.user.real_name);
// Keep convo lean
await summarizeAndTrim(convId);
// Build context for OpenAI
const { input } = await buildContext(convId);
// (Optional) also pass previous_response_id if your last assistant turn stored one:
const lastAssistant = await dbGetLastAssistantMessage(convId);
const previous_response_id = lastAssistant?.response_id || undefined;
// Call Responses API
const resp = await openai.responses.create({
model: 'gpt-5.1',
input,
// previous_response_id, // include if you want cache continuity; not required if you fully rehydrate
});
const botText = resp.output_text;
// Persist assistant message with response IDs
const msgId = makeId();
await dbCreateMessage({
id: msgId,
conversation_id: convId,
role: 'assistant',
actor_id: 'bot',
source: 'bot',
content: botText,
response_id: resp.id,
previous_response_id: previous_response_id || null
});
// Post back to Slack (in the same thread)
await client.chat.postMessage({
channel,
thread_ts: threadTs,
text: botText
});
});
(async () => { await slack.start(); })();
```
**Why this works well**
* You **own state** (messages + roles) and can rebuild context every turn (most robust).
* You **optionally** pass `previous_response_id` as a small optimization, but you don’t depend on it.
* Rolling summaries keep prompts slim; you still preserve fidelity for “what changed” recently.
---
# 4) Reliability, privacy, and ergonomics
* **Idempotency**: When handling Slack retries or replays, upsert a `messages(slack_ts UNIQUE, conversation_id)` so repeats don’t duplicate.
* **Pagination**: For backfills (historic context), page `conversations.history` and seed your DB once; thereafter, only ingest new events.
* **Roles**:
* Maintain a **roster** table if you have course lists; upgrade `guessRole()` to “student/TF/prof”.
* You can weight roles in your system instructions (e.g., “If professor asks, reveal scaffolding; with students, don’t give final solutions outright,” etc.).
* **Attachments/files**: Store links in `messages.meta` and optionally **pull text** (OCR, PDFs) to include short extracts + URLs.
* **Token control**: Cache a token count per message to trim without re-tokenizing on every turn.
* **Summarizer guardrails**: Use a different, cheaper model for compression and instruct it to **keep names, roles, decisions, due dates**.
* **FERPA-ish hygiene** (since you’re in higher-ed):
* Keep PII minimal; encrypt at rest if feasible.
* Add a “redact before log” step for phone/emails.
* Expose an admin command to purge a conversation or a participant’s messages.
* **Observability**:
* Log `conversation_id`, `response_id`, tokens in/out, and latency.
* Keep a lightweight “evaluation” flag you can toggle to sample interactions for quality review.
---
## Variations you might like
* **Hybrid RAG**: store high-value thread artifacts (rubrics, syllabus snippets) in a vector index and inject top-k chunks into the prompt alongside your window.
* **Per-speaker memory**: short, curated bullets (not raw logs) that follow that person when they speak again (“TF Alex prefers Socratic hints,” “Student Kim has ADA accommodation: extended time”).
* **Thread roles**: switch `system_instructions` dynamically (e.g., office hours vs. homework vs. exam week).
---
If you want, I can tailor this to your current stack (SQLite + Drizzle/Prisma, or Postgres) and wire in a tiny migration plus a token-budgeting utility.