Persistent Outbound Connectivity with Slack via Agent Skills

READER BEWARE: THE FOLLOWING WRITTEN ENTIRELY BY AI WITHOUT HUMAN EDITING.

Overview

This post explores a pattern for keeping a containerised agent’s input and output in sync with a Slack channel — without coupling the agent process directly to the Slack API. The coupling is kept intentionally loose: a small set of skills owned by the agent act as the bridge, and the only permanent external dependency from the container is an outbound Slack connection that each skill uses on demand.

The result is a workflow where a container comes online, announces itself to a Slack channel via an approval step, and then mirrors its conversational I/O bidirectionally with Slack for the lifetime of the container.


Architectural View

┌─────────────────────────────────────────────────────┐
│  Container                                          │
│                                                     │
│  ┌───────────────┐        ┌───────────────────────┐ │
│  │  Agent        │◄──────►│  Skill layer          │ │
│  │  (LLM + loop) │        │  - join_channel skill │ │
│  │               │        │  - post_output skill  │ │
│  │               │        │  - relay_input skill  │ │
│  └───────────────┘        └──────────┬────────────┘ │
│                                      │ outbound HTTPS│
└──────────────────────────────────────┼───────────────┘
                                       │
                              ┌────────▼────────┐
                              │  Slack API       │
                              │  (Web API +      │
                              │   Events API)    │
                              └────────┬────────┘
                                       │
                              ┌────────▼────────┐
                              │  Slack channel  │
                              │  (human users)  │
                              └─────────────────┘

Key properties of this design:

  • Single outbound path. All traffic from the container to Slack flows through one persistent HTTPS connection. There is no inbound port opened on the container.
  • Decoupled by design. The agent process does not import a Slack SDK directly. Each skill is a discrete callable that the agent invokes; the skill owns the Slack token and the API surface.
  • Lifecycle managed by skills. Joining a channel, relaying output, and listening for input are each their own skill, making them independently replaceable.

Skills Needed for the Complete Interaction Lifecycle

1. join_channel — Channel Approval Skill

Called once at container startup. It sends a request to a designated admin channel asking for approval to join a target channel. The skill polls or subscribes for the approval reaction/message and, once received, calls conversations.join on behalf of the bot.

Inputs: target channel name, approval channel ID, bot token
Outputs: joined channel ID, confirmation timestamp
Side effects: bot user appears in the target channel

2. post_output — Agent → Slack Relay Skill

Called every time the agent produces a response that should be visible in Slack. The skill formats the agent’s plain-text or Markdown output into Slack’s Block Kit format and calls chat.postMessage.

Inputs: channel ID, agent response text, optional thread timestamp
Outputs: message timestamp (ts) for threading future replies
Side effects: message visible to all channel members

3. relay_input — Slack → Agent Relay Skill

A long-running coroutine (or a webhook handler if running behind a proxy) that listens on the Slack Events API for message events addressed to the bot. It filters messages by bot mention (@agentname) or by a configured trigger phrase, then injects the stripped text into the agent’s input queue.

Inputs: Slack signing secret, listening socket or webhook URL
Outputs: stream of user messages pushed to the agent’s input queue
Side effects: acknowledgement 200 OK returned to Slack within 3 s

4. leave_channel — Graceful Shutdown Skill

Called during container shutdown (SIGTERM handler). Posts a farewell message and calls conversations.leave so the bot user is removed cleanly.

Inputs: channel ID, bot token
Outputs: none
Side effects: bot user removed from channel; historical messages retained


Sequence Diagram — Full Lifecycle

Human          Slack            Skill Layer         Agent
  │              │                  │                 │
  │              │   Container starts                 │
  │              │                  │◄── startup ─────│
  │              │                  │                 │
  │  [admin sees join request]      │                 │
  │──approve──►  │                  │                 │
  │              │──approval evt──► │                 │
  │              │                  │─ conversations  │
  │              │                  │  .join ────────►│
  │              │◄─ bot joins ─────│                 │
  │              │                  │                 │
  │──@agent hi──►│                  │                 │
  │              │──message evt───► │                 │
  │              │                  │──inject text───►│
  │              │                  │                 │──LLM call──►
  │              │                  │◄── response ────│
  │              │◄─ post_output ───│                 │
  │◄─ reply ─────│                  │                 │
  │              │                  │                 │
  │  [container receives SIGTERM]   │                 │
  │              │                  │◄── shutdown ────│
  │              │◄─ leave_channel ─│                 │
  │              │  bot leaves      │                 │

Execution View — What Runs Inside the Container

At runtime the container hosts three concurrent processes:

ProcessRole
Agent loopReads from input queue; calls LLM; writes to output queue
relay_input coroutineReads from Slack Events socket; writes to input queue
post_output consumerReads from output queue; calls chat.postMessage

The input and output queues are in-process (e.g. asyncio.Queue) so there is no external message broker dependency. The agent loop is entirely unaware of Slack — it only sees a queue of strings.

# Pseudocode — container entrypoint
async def main():
    channel_id = await join_channel_skill.run()

    input_q: asyncio.Queue[str] = asyncio.Queue()
    output_q: asyncio.Queue[str] = asyncio.Queue()

    await asyncio.gather(
        agent_loop(input_q, output_q),
        relay_input_skill.run(input_q),
        post_output_skill.run(channel_id, output_q),
    )

User Experience Inside Slack

From a Slack user’s perspective the workflow is straightforward:

  1. Container comes online. An automated message appears in the admin channel:

    “Agent data-pipeline-worker requests to join #ml-ops. React with ✅ to approve.”

  2. Admin approves. The admin reacts with ✅. The bot joins #ml-ops and posts:

    "👋 data-pipeline-worker is online and ready. Mention me or use /agent to interact."

  3. User interacts. The user types @data-pipeline-worker run validation on today's batch in #ml-ops. The agent processes the request and its response appears as a threaded reply within seconds.

  4. Ongoing conversation. Subsequent messages in the same thread are automatically routed to the agent without requiring a fresh mention, keeping the channel readable.

  5. Container shuts down. The bot posts a final message:

    "🔴 data-pipeline-worker is going offline. Goodbye."
    The bot then leaves the channel.


Security and Operational Considerations

  • Bot token scoping. Grant only chat:write, channels:join, channels:read, and app_mentions:read. Avoid admin.* scopes.
  • Signing secret validation. The relay_input skill must verify Slack’s X-Slack-Signature header on every inbound event to prevent spoofed messages.
  • Token storage. Inject the bot token via an environment variable or a secrets manager at startup; never bake it into the image.
  • Egress-only networking. The container needs outbound HTTPS on port 443 only. No inbound ports are required when using Slack’s Socket Mode.
  • Graceful shutdown budget. The leave_channel skill should complete within the container’s SIGTERM-to-SIGKILL window (typically 30 s).

Choosing Socket Mode vs. Webhooks

Socket ModeOutbound Webhook
Inbound port neededNoYes (or a proxy)
Works behind NAT/firewallYesRequires tunnel
Reconnect logicBuilt into SDKManual
Best forContainers without public ingressServices with stable public endpoints

For containers that should not expose inbound ports, Socket Mode is the correct choice. The Slack SDK establishes a persistent WebSocket from the container to Slack’s servers; all event delivery flows over that connection.


Summary

The pattern described here keeps the agent process clean and testable by routing all Slack I/O through skills. The container needs only outbound internet access. Channel membership is gated behind an explicit human approval step. Bidirectional I/O is handled by three independent skills that can be swapped, mocked, or disabled individually without touching the agent loop.

This approach scales naturally: the same agent loop can be connected to a different channel, a different workspace, or a completely different messaging platform simply by replacing the skill layer.