Sage is an internal Slack bot that answers employee questions by retrieving from company documents (RAG), remembers context per conversation thread, and decides on its own when to call external tools (web search, dynamic data lookups) once a question goes beyond the docs.
This is a portfolio project demonstrating AI Engineer skills: designing automation workflows with n8n, integrating an LLM through an AI Agent, building a RAG pipeline with a vector DB, and wiring it all into Slack via the Events API.
An internal Q&A bot sounds simple, but doing it right means solving three problems most AI demos skip:
- Hallucination resistance: when a question falls outside the documents, Sage says "I couldn't find this in our internal docs" instead of making up a plausible-sounding answer. This is the biggest risk of putting an LLM in front of real employees.
- Knowing when NOT to use RAG: questions about dynamic data (who's on-call, how many tasks are left in the sprint) would be answered wrong by vector search — Sage classifies the question and calls the right tool instead.
- Remembering context without bloating tokens: conversation history is kept per thread without re-fetching the entire Slack history on every message.
flowchart TD
U["👤 Employee @mentions Sage in a thread"] --> T["⚡ Slack Trigger"]
T --> F{"🛑 Check Not Bot"}
F -- human message --> AGENT
subgraph Agent["🧠 AI Agent — Sage"]
AGENT(["Decides: answer directly,<br/>search docs, or call a tool"])
end
subgraph Model["💬 Language Model"]
D["Chat Model<br/>OpenClaude · mimo-v2.5-pro"]
end
subgraph Memory["🗂️ Conversation Memory"]
E["Window Buffer Memory<br/>key = thread_ts, last 10 turns"]
end
subgraph RAG["📚 Internal Knowledge — RAG"]
Fdoc["Search Internal Docs<br/>Supabase pgvector"]
G["Embeddings · Ollama<br/>nomic-embed-text"]
Fdoc --- G
end
subgraph Tools["🛠️ Live Data & External Tools"]
H["🌐 Web Search<br/>Tavily API"]
I["👥 Employee Directory"]
J["📅 On-call Schedule"]
K["📊 Sprint Status"]
end
D -.model.-> AGENT
E -.memory.-> AGENT
Fdoc -.tool.-> AGENT
H -.tool.-> AGENT
I -.tool.-> AGENT
J -.tool.-> AGENT
K -.tool.-> AGENT
AGENT --> L["🤖 Slack Reply — posted in the same thread"]
classDef trigger fill:#36C5F0,color:#000,stroke:#0b7ba7,stroke-width:1px
classDef agent fill:#ECB22E,color:#000,stroke:#a3760a,stroke-width:2px
classDef rag fill:#2EB67D,color:#fff,stroke:#1a7a52,stroke-width:1px
classDef tool fill:#E01E5A,color:#fff,stroke:#9c1440,stroke-width:1px
classDef model fill:#4A90D9,color:#fff,stroke:#2f5f8f,stroke-width:1px
classDef endpoint fill:#4A154B,color:#fff,stroke:#2e0e30,stroke-width:1px
class U,L endpoint
class T,F trigger
class AGENT agent
class Fdoc,G rag
class H,I,J,K tool
class D,E model
| Component | Technology | Role |
|---|---|---|
| Slack App | Slack Events API (app_mention) |
Receives mentions, forwards to n8n via webhook |
| Orchestration | n8n (AI Agent node) | Drives the whole flow: decides RAG vs. tool, calls the LLM, replies |
| Vector DB | Supabase pgvector | Stores internal doc embeddings, retrieval via match_documents |
| Embedding model | Ollama nomic-embed-text (local, 768-dim) |
Embeds questions and documents — same model at ingest-time and query-time |
| Chat model | OpenClaude (mimo-v2.5-pro, OpenAI-compatible) |
Generates answers, tool-use, decision making |
| Dynamic data | n8n Data Table (employee directory, on-call, sprint status) | Answers questions that need constantly-changing info, which doesn't belong in RAG |
| Web search | Tavily API | Public/real-time information outside the scope of internal docs |
| Memory | n8n Window Buffer Memory, key = thread_ts |
Remembers the last 10 exchanges within the same thread |
slack-ai-assistant/
├── n8n/workflows/slack-rag-bot.json # exported workflow (credentials stripped)
├── docs-source/ # internal documents used for RAG
├── dynamic-data/ # dynamic data for the Lookup tools (employee, on-call, sprint)
├── ingestion/
│ ├── chunk_and_embed.py # chunks + embeds docs, pushes to Supabase
│ └── requirements.txt
├── slack-app/manifest.yml # Slack App manifest (scopes, event subscriptions)
├── tests/sample_questions.md # manual test question set, used for the demo recording
└── .env.example # environment variable template (no real secrets)
In your Supabase project's SQL Editor, run:
create extension if not exists vector;
create table documents (
id bigserial primary key,
content text,
metadata jsonb,
embedding vector(768)
);
create or replace function match_documents (
query_embedding vector(768),
match_count int default 5,
filter jsonb default '{}'
) returns table (
id bigint,
content text,
metadata jsonb,
similarity float
)
language plpgsql
as $$
begin
return query
select
documents.id,
documents.content,
documents.metadata,
1 - (documents.embedding <=> query_embedding) as similarity
from documents
where documents.metadata @> filter
order by documents.embedding <=> query_embedding
limit match_count;
end;
$$;
create index on documents using hnsw (embedding vector_cosine_ops);The
768dimension matches Ollama'snomic-embed-textembedding model — if you switch embedding models, update this dimension in both the table and the ingestion script.
cd ingestion
pip install -r requirements.txt
cp ../.env.example ../.env # fill in real values in .env, do NOT commit this file
python chunk_and_embed.pyThis script requires Ollama running locally (ollama pull nomic-embed-text first) and the SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY environment variables.
Go to api.slack.com/apps → Create New App → From an app manifest → paste the contents of slack-app/manifest.yml. After creation, install the app to your workspace and grab the Bot User OAuth Token (xoxb-...) and Signing Secret.
Import n8n/workflows/slack-rag-bot.json into n8n. After importing, reconfigure the 3 credentials (stripped on export):
- Slack API — the Bot Token + Signing Secret from step 3
- Chat Model (OpenAI-compatible) — OpenClaude's API key + base URL
- Supabase API — Project URL + service_role key
- Ollama — local base URL (default
http://localhost:11434)
Activate the workflow, then mention the bot in Slack to test.
Use the sample questions in tests/sample_questions.md, split into 4 groups for the video/GIF recording:
- RAG questions answered correctly from the docs
- Out-of-scope questions (verify the bot refuses to make up an answer)
- Questions needing external tools (web search / dynamic data)
- Follow-up questions within the same thread (verify context memory)
- No complex multi-language handling (basic Vietnamese/English only)
- No SSO, just basic Slack OAuth
- No queue system — suited for demo/small-scale internal use, not optimized for production scale