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:
| Process | Role |
|---|---|
| Agent loop | Reads from input queue; calls LLM; writes to output queue |
relay_input coroutine | Reads from Slack Events socket; writes to input queue |
post_output consumer | Reads 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:
Container comes online. An automated message appears in the admin channel:
“Agent
data-pipeline-workerrequests to join#ml-ops. React with ✅ to approve.”Admin approves. The admin reacts with ✅. The bot joins
#ml-opsand posts:"👋
data-pipeline-workeris online and ready. Mention me or use/agentto interact."User interacts. The user types
@data-pipeline-worker run validation on today's batchin#ml-ops. The agent processes the request and its response appears as a threaded reply within seconds.Ongoing conversation. Subsequent messages in the same thread are automatically routed to the agent without requiring a fresh mention, keeping the channel readable.
Container shuts down. The bot posts a final message:
"🔴
data-pipeline-workeris going offline. Goodbye."
The bot then leaves the channel.
Security and Operational Considerations
- Bot token scoping. Grant only
chat:write,channels:join,channels:read, andapp_mentions:read. Avoidadmin.*scopes. - Signing secret validation. The
relay_inputskill must verify Slack’sX-Slack-Signatureheader 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_channelskill should complete within the container’s SIGTERM-to-SIGKILL window (typically 30 s).
Choosing Socket Mode vs. Webhooks
| Socket Mode | Outbound Webhook | |
|---|---|---|
| Inbound port needed | No | Yes (or a proxy) |
| Works behind NAT/firewall | Yes | Requires tunnel |
| Reconnect logic | Built into SDK | Manual |
| Best for | Containers without public ingress | Services 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.