# 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.