---
title: "Building AI Agents for DevOps Part 2: From Terminal to Slack"
url: "https://tirraflow.com/posts/building-ai-agents-for-devops-part-2-from-terminal-to-slack"
author: "taoufiq"
published: "2026-03-16T23:12:36Z"
updated: "2026-10-15T21:16:04Z"
category: "DevOps & SRE"
difficulty: "intermediate"
series: "Building AI Agents for DevOps"
series_part: 2
series_url: "https://tirraflow.com/series/building-ai-agents-for-devops"
tags: ["Agents", "ADK"]
---

# Building AI Agents for DevOps Part 2: From Terminal to Slack

> **Updated for Orrery 0.6.0 (October 2026).** One thread is still one session, and approvals still use buttons. Since then, only the person who asked can approve an action. Slow turns post one status message that is edited live as each specialist works. And with impersonation on, Kubernetes and Elasticsearch calls run as the Slack user. The live status message is covered in [Part 16](https://tirraflow.com/posts/building-ai-agents-for-devops-part-16-streaming-what-the-agent-is-doing), impersonation in [Part 17](https://tirraflow.com/posts/building-ai-agents-for-devops-part-17-opening-the-platform-without-opening-the-gates).

In [Part 1](https://tirraflow.com/posts/building-ai-agents-for-devops-part-1-why-and-how-i-designed-the-architecture), I built a DevOps agent platform where AI agents monitor Kafka, Kubernetes, and Docker. It worked great — in the terminal and the ADK web UI.

But here's the thing: when you're paged at 2 AM, you're not opening a web dashboard. You're in Slack, looking at alerts. So I brought the agent to where DevOps teams actually work.

This post covers how I built the Slack integration, the design decisions behind it, and how confirmation buttons replace the text-based "are you sure?" flow.

---

## What It Looks Like

Here's a real conversation. I ask about Kafka, request a topic creation (which requires confirmation), and check running containers — all from a Slack thread:

![Slack thread: asking the agent about Kafka, confirming a topic creation, and listing running containers](https://tirraflow.com/uploads/9733cb1f-46a2-4510-81c3-83a4b09bd681.jpg)

The agent uses the same tools, the same sub-agents, the same guardrails as in the terminal. The only difference is the interface.

---

## The Key Idea: Same Agent, Different Transport

The Slack bot is not a separate agent. It imports the **exact same** `root_agent` from the devops-assistant and wraps it with Slack event handling:

```python
from devops_assistant.agent import root_agent  # same agent as CLI/web

runner = Runner(agent=root_agent, app_name="slack_devops", session_service=session_service)
```

This means every improvement to the agent — new tools, better instructions, new sub-agents — is automatically available in Slack. Zero duplication.

---

## Architecture

The flow from Slack message to agent response looks like this:

```mermaid
flowchart TB
    subgraph ws["Slack Workspace"]
        user["User: “what is the status of kafka?”"]
        events["Slack Events API"]
        user --> events
    end

    subgraph bot["slack-bot (FastAPI + slack-bolt)"]
        session["Session Map<br/>thread_ts → session_id"]
        handler["Handler<br/>Slack msg → ADK Runner"]
        confirm["Confirmation Store<br/>Approve / Deny"]
        runner["ADK Runner<br/>(run_async)"]
        handler --> runner
        session -.-> handler
        confirm -.-> handler
    end

    subgraph agents["devops-assistant (root agent)"]
        a1[kafka agent]
        a2[k8s agent]
        a3[docker agent]
        a4[observability agent]
    end

    events -->|"WebSocket (Socket Mode)"| handler
    %% Edges to each agent rather than one edge to the subgraph: a subgraph's
    %% own direction is ignored when the parent flowchart is TB and its nodes
    %% have no edges, which stacked these four in a column and made the diagram
    %% 2,100px tall. Fanning out from the runner lays them in a row.
    runner --> a1 & a2 & a3 & a4
```

_Slack workspace connecting over Socket Mode to the slack-bot service, which runs the ADK runner against the devops-assistant agent and its four specialists_

Three important design decisions here:

---

## Decision 1: One Thread = One Session

Slack threads are a natural fit for conversation boundaries. Each thread maps to one ADK session:

```mermaid
flowchart LR
    subgraph slack["Slack"]
        t1["Thread “1234.5678”<br/>message 1, 2, 3"]
        t2["Thread “9999.0000”<br/>message 1"]
    end
    subgraph adk["ADK"]
        s1["Session “sess_abc”<br/>state, history"]
        s2["Session “sess_xyz”<br/>fresh state"]
    end
    t1 --> s1
    t2 --> s2
```

_Each Slack thread maps to its own ADK session, so two threads keep separate state and history_

The `SessionMap` handles this mapping:

```python
class SessionMap:
    def __init__(self):
        self._map: dict[tuple[str, str], str] = {}  # (channel, thread_ts) → session_id

    def get(self, channel: str, thread_ts: str) -> str | None:
        return self._map.get((channel, thread_ts))

    def set(self, channel: str, thread_ts: str, session_id: str) -> None:
        self._map[(channel, thread_ts)] = session_id
```

When a message arrives in a new thread, a new ADK session is created. Subsequent messages in the same thread continue the same session — the agent remembers the conversation context.

---

## Decision 2: Confirmation via Buttons, Not Text

In Part 1, I showed how the `require_confirmation()` callback gates destructive tools. In the CLI, it works through text: the agent asks "are you sure?", the user types "yes".

In Slack, text-based confirmation is clunky. Instead, guarded tools post **interactive Block Kit buttons**:

```mermaid
flowchart TB
    invoke["Tool call:<br/>delete_kafka_topic(topic=&quot;events&quot;)"] --> cb

    subgraph cb["slack_confirmation callback"]
        direction TB
        c1["1. Is this tool guarded? → YES"]
        c2["2. Store pending confirmation"]
        c3["3. Post Slack buttons"]
        c4["4. Block the tool<br/>(return pending status)"]
        c1 --> c2 --> c3 --> c4
    end

    cb --> card["⚠️ DESTRUCTIVE: delete_kafka_topic<br/>topic=events<br/>[ Approve ]  [ Deny ]"]
    card -->|User clicks Approve| resume["Handler sends “Yes, proceed” to the runner"]
    resume --> retry["Agent re-invokes the tool"]
    retry --> pass["Callback sees pending flag → allows through"]
    pass --> exec["Tool executes"]
```

_A destructive tool call is blocked by the confirmation callback, which posts Approve and Deny buttons; approving re-invokes the tool, which the callback now lets through_

The key is that the Slack callback **reuses the same guardrail metadata** from the core library. It reads `@destructive` and `@confirm` decorators but replaces the text flow with buttons:

```python
from ai_agents_core import get_guard_level, get_guard_reason, LEVEL_DESTRUCTIVE

def slack_confirmation(store, slack_client, channel_ref):
    def callback(*, tool, args, tool_context):
        func = getattr(tool, "func", None)
        level = get_guard_level(func)
        if level is None:
            return None  # safe tool, proceed

        # Already confirmed? Allow through
        pending_key = f"_guardrail_pending_{tool.name}"
        if tool_context.state.get(pending_key):
            tool_context.state[pending_key] = False
            return None  # proceed

        # Block and post buttons
        tool_context.state[pending_key] = True
        blocks = build_confirmation_blocks(tool.name, args, ...)
        slack_client.chat_postMessage(channel=..., blocks=blocks)
        return {"status": "confirmation_required", ...}

    return callback
```

The core library stays transport-agnostic. The Slack bot adds its own confirmation UI on top.

---

## Decision 3: Socket Mode for Local Dev

Slack's Events API normally requires a public HTTPS endpoint. For local development, that means ngrok or a tunnel.

Instead, I used **Socket Mode** — Slack connects to your app via outbound WebSocket. No public URL, no tunnel, works behind any firewall:

```mermaid
flowchart TB
    subgraph webhook["Webhook mode — needs a public URL"]
        direction LR
        w1["Slack API"] -->|"webhook (HTTP)"| w2["Your server<br/>(ngrok, etc.)"]
    end

    subgraph socket["Socket Mode — works anywhere"]
        direction LR
        s2["Your machine<br/>(localhost)"] -->|"socket (WSS)"| s1["Slack API"]
    end
```

_Webhook mode needs a public URL for Slack to call into; Socket Mode dials out over a WebSocket and works from anywhere_

Setup takes 5 minutes:

```bash
# Configure tokens in .env
make up                    # start Kafka, Prometheus, etc.
make run-slack MODE=socket # connect to Slack via WebSocket
```

---

## The Message Handler

The core handler is straightforward — it bridges Slack events to the ADK Runner:

```python
async def handle_message(self, *, text, channel, thread_ts, user_id, say):
    # 1. Map thread to session
    session_id = self.session_map.get(channel, thread_ts)
    if session_id is None:
        session = await self.session_service.create_session(
            app_name="slack_devops", user_id=user_id,
        )
        session_id = session.id
        self.session_map.set(channel, thread_ts, session_id)

    # 2. Send to ADK Runner
    message = types.Content(role="user", parts=[types.Part.from_text(text=text)])
    response_text = ""
    async for event in self.runner.run_async(
        user_id=user_id, session_id=session_id, new_message=message,
    ):
        if event.content and event.content.parts:
            for part in event.content.parts:
                if part.text:
                    response_text += part.text

    # 3. Post response in thread
    formatted = md_to_mrkdwn(response_text)
    for chunk in chunk_message(formatted):
        await say(text=chunk, thread_ts=thread_ts)
```

The `md_to_mrkdwn` function handles the translation from Markdown (what the LLM produces) to Slack's mrkdwn format (what Slack renders). The `chunk_message` function splits long responses so they don't hit Slack's message length limits.

---

## Package Structure

The Slack bot follows the same workspace pattern as every other agent:

```
agents/slack-bot/
├── pyproject.toml             ← workspace package, depends on devops-assistant
├── slack_bot/
│   ├── app.py                 ← FastAPI + slack-bolt (webhook mode)
│   ├── socket_mode.py         ← Socket Mode entry point (local dev)
│   ├── handler.py             ← Slack event → ADK Runner → Slack response
│   ├── confirmation.py        ← Slack buttons for @destructive/@confirm tools
│   ├── session_map.py         ← thread_ts ↔ session_id mapping
│   ├── formatting.py          ← Markdown → Slack mrkdwn
│   └── config.py              ← SlackBotConfig(AgentConfig)
└── tests/                     ← 35 tests, all mocked
```

It depends on `devops-assistant` (for the root agent) and `ai-agents-core` (for guardrail introspection). Everything else is self-contained.

---

## What I Learned

### 1. `say()` is async in slack-bolt

This one bit me. In slack-bolt's `AsyncApp`, the `say()` function is a coroutine. If you forget `await`, you get a silent failure — the message never posts, and you only see a `RuntimeWarning` in the logs. Always `await say()`.

### 2. SQLite needs the async driver

ADK's `DatabaseSessionService` uses SQLAlchemy async. The default `sqlite:///` URL loads the synchronous `pysqlite` driver, which throws an error. The fix: `sqlite+aiosqlite:///`.

### 3. Keep the core transport-agnostic

I was tempted to add Slack-specific logic to `core/guardrails.py`. Instead, I exposed `get_guard_level()` and `get_guard_reason()` as public APIs and built the Slack confirmation as a separate callback. Now any future transport (MS Teams, Discord, a mobile app) can do the same thing without touching the core.

---

## Try It Yourself

The setup takes about 5 minutes:

1. Create a Slack app from the manifest (JSON is in the [repo README](https://github.com/BAHALLA/orrery))
2. Generate tokens (Bot Token + App-Level Token for Socket Mode)
3. Add them to `agents/slack-bot/.env`
4. Run:

   ```bash
   make up && make run-slack MODE=socket
   ```

5. Invite the bot to a channel and start chatting

---

## What's Next

The Slack bot gives us identity for free — every message comes with a Slack user ID. In the next part, I'll build on this to add **IAM and role-based access control**: who can run read-only tools, who can approve destructive operations, and how to tie it all to your existing SSO.

The audit trail from Part 1 + Slack user identity + RBAC = knowing not just _what_ happened, but _who_ authorized it and _why_.

---

_The full source code is on_ [_GitHub_](https://github.com/BAHALLA/orrery)_. Star it if you find it useful._
