SlackBot WS (WebSocket) is a production-ready Slack bot framework for Elixir built for Slack's Socket Mode. It gives you a supervised WebSocket connection, Slack's API tier rate limiting, an elegant slash-command parsing DSL, Plug-like middleware, and comprehensive Telemetry coverage. All the typical side-mission complexity that pulls you away from just building features is eliminated.
Slack's Socket Mode shines when you need real-time event delivery without a public HTTP endpoint: laptops, firewalled environments, or stacks where inbound webhooks are undesirable. Persistent connections keep latency low, interactive payloads flowing, and local development simple. Socket Mode is fantastic for internal, private bots within an organization; it's not for Slack's public marketplace, where you'd advertise your application to other Slack organizations.
- Resilient Socket Mode connection — supervised transport handles backoff, jittered retries, dedupe, heartbeats, and HTTP-based health checks (
auth.test) so your bot stays online. - Tier-aware rate limiting — per-channel and per-workspace shaping plus Slack's published tier quotas are enforced automatically; override the registry when you need custom allowances.
- Deterministic slash-command grammar — declaratively describe
/deploy api canaryor more complex syntaxes and get structured maps at compile time—no regex piles. - Plug-like routing & middleware —
handle_event,slash, andmiddlewaremacros let you compose pipelines instead of sprawling case statements. - Task-based fan-out — handlers run in supervised tasks so slow commands never block the socket loop.
- Native interactivity + BlockBox — shortcuts, message actions, block suggestions, modal submissions, and optional BlockBox helpers all flow through the same pipeline.
- Pluggable adapters & cache sync — ETS cache/event buffer by default; swap to Redis for multi-node, configure cache sync, and set assigns such as
:bot_user_idfor zero-cost membership checks. - Observability & diagnostics — telemetry spans, optional telemetry stats, diagnostics ring buffer with replay, and LiveDashboard-ready metrics.
- Production defaults out of the box — add tokens, supervise the module, and you have heartbeats, backoff, and rate limiting without touching config.
New to Slack bots? The Getting Started guide walks through creating a Slack App, enabling Socket Mode, obtaining tokens, and running your first handler.
defmodule MyApp.SlackBot do
use SlackBot, otp_app: :my_app
# /deploy api → %{service: "api"}
# /deploy api canary → %{service: "api", canary?: true}
slash "/deploy" do
value :service
optional literal("canary", as: :canary?)
repeat do
literal "env"
value :envs
end
handle payload, ctx do
%{service: svc, envs: envs} = payload["parsed"]
Deployments.kick(svc, envs, ctx)
end
end
end| Input | Parsed |
|---|---|
/deploy api |
%{service: "api"} |
/deploy api canary env staging env prod |
%{service: "api", canary?: true, envs: ["staging", "prod"]} |
See the Slash Grammar Guide for the full macro reference.
SlackBot routes events through a Plug-like pipeline. Middleware runs before handlers and can short-circuit with {:halt, response}. Multiple handle_event clauses for the same type run in declaration order.
defmodule MyApp.Router do
use SlackBot
defmodule LogMiddleware do
def call("message", payload, ctx) do
Logger.debug("incoming: #{payload["text"]}")
{:cont, payload, ctx}
end
def call(_type, payload, ctx), do: {:cont, payload, ctx}
end
middleware LogMiddleware
handle_event "message", payload, ctx do
Cache.record(payload)
end
handle_event "message", payload, ctx do
Replies.respond(payload, ctx)
end
enddefmodule MyApp.SlackBot do
use SlackBot, otp_app: :my_app
handle_event "app_mention", event, _ctx do
MyApp.SlackBot.push({"chat.postMessage", %{
"channel" => event["channel"],
"text" => "Hi <@#{event["user"]}>!"
}})
end
endMyApp.SlackBot.push/1is synchronous and waits for Slack's response via the managed HTTP pool, telemetry pipeline, and rate limiter.MyApp.SlackBot.push_async/1is fire-and-forget under the supervised Task pipeline—perfect for long-running replies or batched API work.
Add SlackBot to your mix.exs:
def deps do
[
{:slack_bot_ws, "~> 0.1.0"}
]
endThen fetch dependencies:
mix deps.getIf you have Igniter installed, run mix slack_bot_ws.install to scaffold a bot module, config, and supervision wiring automatically.
defmodule MyApp.SlackBot do
use SlackBot, otp_app: :my_app
handle_event "message", event, _ctx do
MyApp.SlackBot.push({"chat.postMessage", %{
"channel" => event["channel"],
"text" => "Hello from MyApp.SlackBot!"
}})
end
endHow this works:
use SlackBot, otp_app: :my_appturns the module into a router that reads configuration from your app's environment and injects the DSL macros (handle_event,slash,middleware).handle_event/3pattern-matches on Slack event types. The first argument ("message") is the event type to match.eventis the raw payload map from Slack—it contains fields like"channel","user","text", and"ts"depending on the event type. You destructure what you need.ctxis the per-event context struct carrying the telemetry prefix, assigns (custom data you configure), and HTTP client. We mark it_ctxhere because this simple example doesn't use it, but middleware and more complex handlers often pass data throughctx.assigns.MyApp.SlackBot.push/1sends Web API requests through the managed rate limiter, Telemetry pipeline, and HTTP pool. It returns{:ok, response}or{:error, reason}.
In config/config.exs:
config :my_app, MyApp.SlackBot,
app_token: System.fetch_env!("SLACK_APP_TOKEN"),
bot_token: System.fetch_env!("SLACK_BOT_TOKEN")children = [
MyApp.SlackBot
]
Supervisor.start_link(children, strategy: :one_for_one)That's it. SlackBot boots a Socket Mode connection with ETS-backed cache and event buffer, per-workspace/per-channel rate limiting, and default backoff/heartbeat settings. When you're ready to tune behavior, read on.
The ergonomic path is one module per bot using the otp_app pattern. Each module gets its own push/1, push_async/1, emit/1, and config/0 helpers so you always call the right instance:
defmodule MyApp.CustomerSuccessBot do
use SlackBot, otp_app: :my_app
end
defmodule MyApp.IncidentBot do
use SlackBot, otp_app: :my_app
end
children = [
MyApp.CustomerSuccessBot,
MyApp.IncidentBot
]
Supervisor.start_link(children, strategy: :one_for_one)Need distinct runtime instances of the same router module (for example, dynamically named bots per workspace)? Start SlackBot directly with an explicit :name and call the explicit APIs:
children = [
{SlackBot, name: :team_alpha_bot, module: MyApp.DynamicRouter, app_token: ..., bot_token: ...},
{SlackBot, name: :team_beta_bot, module: MyApp.DynamicRouter, app_token: ..., bot_token: ...}
]
SlackBot.push(:team_alpha_bot, {"chat.postMessage", %{"channel" => "C123", "text" => "hi"}})Avoid mixing the module helpers in this scenario—the helpers assume the supervised process is registered under the module name. Pick one style per instance so the codebase stays predictable.
Background jobs and tooling can also pass a %SlackBot.Config{} directly when they already have one on
hand:
config = SlackBot.config(MyApp.SlackBot)
SlackBot.emit(config, {"daily_digest", %{"channels" => ["C123"]}})Use this sparingly (for example telemetry probes or test helpers) and prefer the module helpers inside your application code.
Every option below is optional—omit them and SlackBot uses production-ready defaults.
config :my_app, MyApp.SlackBot,
backoff: %{min_ms: 1_000, max_ms: 30_000, max_attempts: :infinity, jitter_ratio: 0.2},
log_level: :info,
health_check: [enabled: true, interval_ms: 30_000]config :my_app, MyApp.SlackBot,
telemetry_prefix: [:slackbot],
telemetry_stats: [enabled: true, flush_interval_ms: 15_000, ttl_ms: 300_000]When telemetry_stats is enabled, SlackBot.TelemetryStats.snapshot/1 returns rolled-up counters for API calls, handlers, rate/tier limiters, and connection states.
# ETS (default)
cache: {:ets, []}
event_buffer: {:ets, []}
# Redis for multi-node
event_buffer:
{:adapter, SlackBot.EventBuffer.Adapters.Redis,
redis: [host: "127.0.0.1", port: 6379], namespace: "slackbot"}Per-channel and per-workspace shaping is enabled by default. Disable it only if you're shaping traffic elsewhere:
rate_limiter: :noneSlack's per-method tier quotas are also enforced automatically. Override entries via the tier registry:
config :slack_bot_ws, SlackBot.TierRegistry,
tiers: %{
"users.list" => %{max_calls: 10, window_ms: 45_000},
"users.conversations" => %{group: :metadata_catalog}
}SlackBot ships with default specs for every Slack Web API method listed in the published tier tables (including special cases like chat.postMessage). Overrides are only necessary when Slack revises quotas or when custom grouping is desired.
See Rate Limiting Guide for a full explanation of how tier-aware limiting works and how to tune it.
ack_mode: :silent # default: no placeholder
ack_mode: :ephemeral # sends "Processing…" via response_url
ack_mode: {:custom, &MyApp.custom_ack/2}diagnostics: [enabled: true, buffer_size: 300]When enabled, SlackBot captures inbound/outbound frames. See Diagnostics Guide for IEx workflows and replay.
cache_sync: [
enabled: true,
kinds: [:channels], # :users is opt-in
interval_ms: :timer.hours(1)
]
user_cache: [
ttl_ms: :timer.hours(1),
cleanup_interval_ms: :timer.minutes(5)
]SlackBot routes events through a Plug-like pipeline. Middleware runs before handlers and can short-circuit with {:halt, response}. Multiple handle_event clauses for the same type run in declaration order.
defmodule MyApp.Router do
use SlackBot
defmodule LogMiddleware do
def call("message", payload, ctx) do
Logger.debug("incoming: #{payload["text"]}")
{:cont, payload, ctx}
end
def call(_type, payload, ctx), do: {:cont, payload, ctx}
end
middleware LogMiddleware
handle_event "message", payload, ctx do
Cache.record(payload)
end
handle_event "message", payload, ctx do
Replies.respond(payload, ctx)
end
endThe slash/2 DSL compiles grammar declarations into deterministic parsers:
slash "/deploy" do
value :service
optional literal("canary", as: :canary?)
repeat do
literal "env"
value :envs
end
handle payload, ctx do
%{service: svc, envs: envs} = payload["parsed"]
Deployments.kick(svc, envs, ctx)
end
end| Input | Parsed |
|---|---|
/deploy api |
%{service: "api"} |
/deploy api canary env staging env prod |
%{service: "api", canary?: true, envs: ["staging", "prod"]} |
See Slash Grammar Guide for the full macro reference.
MyApp.SlackBot.push/1— synchronous; waits for Slack's responseMyApp.SlackBot.push_async/1— fire-and-forget under the managed Task.SupervisorSlackBot.push/2andSlackBot.push_async/2remain available when you need to target a dynamically named instance.
Both variants route through the rate limiter and Telemetry pipeline automatically. Reach for the explicit
SlackBot.* forms when you start bots under dynamic names (multi-tenant supervisors, {:via, ...} tuples) or
when you're operating on a cached %SlackBot.Config{} outside the router (for example a background job or probe).
The module-scoped helpers stay the recommended default for static otp_app bots.
iex> SlackBot.Diagnostics.list(MyApp.SlackBot, limit: 5)
[%{direction: :inbound, type: "slash_commands", ...}, ...]
iex> SlackBot.Diagnostics.replay(MyApp.SlackBot, types: ["slash_commands"])
{:ok, 3}Replay feeds events back through your handlers—useful for reproducing production issues locally. See Diagnostics Guide.
SlackBot emits events for connection state, handler execution, rate limiting, and health checks. Integrate with LiveDashboard or attach plain handlers:
:telemetry.attach(
:slackbot_logger,
[:slackbot, :connection, :state],
fn _event, _measurements, %{state: state}, _ ->
Logger.info("Slack connection: #{state}")
end,
nil
)See Telemetry Guide for metric definitions and LiveDashboard wiring.
The examples/basic_bot/ directory contains a runnable project demonstrating:
- slash grammar DSL with optional/repeat segments
- middleware logging
- diagnostics capture and replay
- auto-ack strategies
- optional BlockBox helpers
Follow the README inside that folder to run it against a Slack dev workspace.
- Getting Started — from Slack App creation to first slash command
- Rate Limiting — how tier-aware limiting works
- Slash Grammar — declarative command parsing
- Diagnostics — ring buffer and replay workflows
- Telemetry Dashboard — LiveDashboard integration
mix deps.get
mix test
mix formatSlackBot.TestTransport and SlackBot.TestHTTP in lib/slack_bot/testing/ let you simulate Socket Mode traffic and stub Web API calls without hitting Slack.
mix test now exercises the Redis event buffer adapter against a live Redis instance:
- If
REDIS_URLis unset, the suite attempts to connect toredis://localhost:6379/0. When Redis is unavailable, it automatically runsdocker run -d --name slackbot-ws-test-redis -p 6379:6379 redis:7-alpineand waits for the container to become healthy. - Provide your own Redis by exporting
REDIS_URL=redis://host:port/db. When this variable is present the helper will not touch Docker; tests will fail fast if the URL is unreachable. - To stop the auto-managed container manually, run
docker stop slackbot-ws-test-redis. The helper also removes stale containers before starting new ones and registers anat_exitcallback so the container stops when the suite finishes. - GitHub Actions uses the same
REDIS_URLand runs a dedicated Redis service container, so CI mirrors local behavior.
- Fork the repository
- Create a feature branch
- Write tests for your changes
- Run
mix testandmix format - Open a pull request
For larger changes, open an issue first to discuss the approach.
MIT.