---
# System prepended metadata

title: plan-for-slackbot-architecture

---

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