All posts

Wire RAG into Confluence, Teams and Slack — without turning it into a platform

A RAG system becomes useful where questions already happen: chat. Three connectors, each a thin adapter, each off by default, each verifying its signatures.

The short answer

Part 3 of the RAG tutorial series adds three optional connectors: Confluence ingestion (page or whole space), a Microsoft Teams outgoing webhook and a Slack Events API bot. Three rules keep it from becoming a platform: every connector is off by default via @ConditionalOnProperty (unconfigured means the beans don't exist and the endpoints answer 404), every webhook signature is verified over the raw request bytes before any JSON parsing, and every connector is a thin adapter into the same ingestion pipeline. Bonus pattern: answered chat Q&A pairs are re-embedded into the vector store, so the bot can retrieve its own prior answers.

A RAG system on a dashboard is a demo. It becomes useful where questions already happen — in the team chat — and where the documents already live — in the wiki. Part 3 of my RAG tutorial series wires the pipeline from part 1 and part 2 into Confluence, Microsoft Teams and Slack. The interesting part isn't any single integration. It's the three rules that keep three integrations from turning a small codebase into a platform.

Rule one: off by default, and "off" means gone

Each connector activates only when its key property is set — rag.confluence.base-url, rag.teams.hmac-secret, rag.slack.signing-secret. Not "disabled but present": the beans carry @ConditionalOnProperty, so an unconfigured connector contributes nothing to the application context. Its endpoints answer 404. There is no half-configured client waiting to throw, no dead code path to secure.

I verified this the blunt way — booted the app with no connector config and curled all three endpoints: 404, 404, 404. Set one environment variable, restart, and the Slack endpoint exists, verifies signatures and answers. Feature flags at the bean level are Spring's most underrated deployment tool: the same artifact runs as a bare RAG API or a fully-wired chat bot, decided by environment.

Rule two: verify signatures over the raw bytes — before parsing

Both chat platforms sign their webhook calls. Teams sends Authorization: HMAC <base64> — an HMAC-SHA256 over the request body with the webhook's security token. Slack signs v0:<timestamp>:<raw body> with your signing secret and sends the hex digest in X-Slack-Signature, plus a timestamp you must bound (~5 minutes) against replays.

The mistake that costs people an afternoon: letting the framework deserialize the JSON first and verifying a re-serialized version. Key order and whitespace shift, and every signature mismatches. The controllers therefore take the body as a raw String, verify over exactly those bytes, and only then parse:

@PostMapping("/slack/events")
public ResponseEntity<?> onEvent(@RequestHeader("X-Slack-Request-Timestamp") String ts,
                                 @RequestHeader("X-Slack-Signature") String sig,
                                 @RequestBody String rawBody) {
    if (!verifier.verify(ts, sig, rawBody)) {
        return ResponseEntity.status(401).build();
    }
    // ...now parse
}

Compare digests constant-time (MessageDigest.isEqual), not with String.equals. And test it honestly: I signed simulated requests with openssl and checked all three paths — valid signature accepted, wrong signature 401, stale timestamp 401. Those curl snippets are in the repo's README, because a webhook you can't test without the real platform is a webhook you'll debug in production.

Rule three: connectors are adapters, not features

All three connectors funnel into the same method the file upload has used since part 1: extract text, chunk, embed, store. Confluence fetches a page (or paginates a whole space), flattens the storage-format HTML with Jsoup, and hands it to that pipeline. The chat bots do the reverse direction — question in, RAG answer out — and each is a thin shell around the same RagService.

The platforms differ where you'd expect: Teams outgoing webhooks want a synchronous JSON reply; Slack retries anything not acknowledged within three seconds, so the controller acks immediately and an @Async handler runs the RAG loop and posts the answer into a thread via chat.postMessage. Get that split wrong on Slack and every answer arrives three times — its retry behaviour makes slow controllers look like duplicate-message bugs.

The pattern worth stealing: the bot learns from its own answers

Every answered Q&A pair — from Teams or Slack — is re-embedded into the vector store, asynchronously, as a small "Question: … / Answer: …" document. The consequence: ask in Slack today what a colleague asked in Teams last month, and the retrieval step can surface that prior exchange as context. Knowledge that only ever existed as a chat message becomes searchable.

It cuts both ways, and I'd rather say so than sell the pattern: a wrong answer gets re-embedded too. In a production system you'd add a feedback signal before persisting. In the tutorial the trade-off is visible and discussed — which is worth more than pretending it away.

The takeaway

Integrations don't have to grow your core. Three external systems joined this codebase and the RAG pipeline didn't change — because every connector obeys the same three rules: gone when unconfigured, signatures over raw bytes, thin adapter into one pipeline. That discipline is what you should demand from any "we'll just add a Slack bot" ticket.

Part 4 packages all of it for production: Docker images for backend and frontend, and a Helm chart where the fully offline stack — local model, own database — is one helm install away.

Code: github.com/halviclabs/rag-tutorials — part 3 is rag-tutorial-03-connectors, including setup guides for the Teams webhook, the Slack app and Confluence tokens, plus a symptom→cause→fix troubleshooting table.

Frequently asked questions

How do I verify Slack request signatures correctly?

Compute HMAC-SHA256 with your signing secret over the string v0:<timestamp>:<raw body> and compare the hex digest against the X-Slack-Signature header using a constant-time comparison, after rejecting timestamps older than about five minutes to block replays. The critical detail: sign the raw body bytes exactly as received — if your framework deserializes the JSON first and you re-serialize it, key order and whitespace change and every signature check fails.

Why must a Slack bot answer asynchronously?

Slack retries any event not acknowledged within three seconds — and an LLM call plus retrieval never fits that budget. The controller verifies the signature, returns 200 immediately, and hands the event to an async handler that runs the RAG loop and posts the answer via chat.postMessage into a thread. Teams outgoing webhooks work the other way: they expect a synchronous JSON reply, so the RAG call happens inline there.

How do I make Spring beans optional based on configuration?

Annotate the connector's beans with @ConditionalOnProperty on its key property — for example rag.slack.signing-secret. Without the property, Spring never instantiates the client, service or controller: the endpoints answer 404, nothing half-configured runs. Setting one environment variable activates the whole connector.

What does re-embedding chat Q&A pairs achieve in a RAG system?

Every answered question is stored back into the vector store as a question-answer document, asynchronously. Future questions — from chat or the dashboard — can then retrieve prior answers as context, so knowledge that only ever existed as a chat exchange becomes searchable. It also means mistakes get re-embedded, so answer quality matters twice.

AI code without tech debt — the checklist

Sign up: the checklist plus new posts on AI engineering. No spam, unsubscribe anytime.