Slink

A lightweight Slack bot toolkit for Elixir. One event-handling contract, two interchangeable transports:

Write your bot once; pick the transport per environment. Built on Mint.WebSocket and Req, with JSON handled by Elixir's built-in JSON module — no hand-rolled WebSocket protocol, no Jason dependency of our own.

Why not slack, slack_elixir, or raw WebSockex?

There is no official Slack SDK for Elixir (Bolt covers JS/Python/Java), and the existing options each leave a gap:

Installation

def deps do
[
{:slink, "~> 0.5"}
]
end

Requires Elixir ~> 1.19 (built-in JSON module, refined compiler type warnings).

Define a bot

defmodule MyBot do
use Slink
alias Slink.Event
@impl true
def handle_event(%Slink.Event{type: :app_mention} = event, context) do
# Reply to what triggered us — threaded if it was in a thread, else inline.
reply(context, "hi <@#{Event.user(event)}> 👋")
end
def handle_event(_event, _context), do: :ok
end

Handlers are stateless. Known Slack event types arrive as atoms (:app_mention, :message, …); unknown ones stay strings. To respond, call reply(context, text, opts) (imported by use Slink) — the channel and thread come from the event carried in the context, opts[:to] picks placement (:auto, :thread, :channel), and extra keys like blocks: ride along for rich replies. reply/3 returns :ok, so a clause can end with it. You can also just return{:reply, text} (or {:reply, text, opts}) and slink sends it. Replies route through a per-channel rate limiter (Slack's ~1 msg/sec/channel). For other Web API calls use Slink.API directly.

Both transports acknowledge the event to Slack before your handler runs and dispatch it off-process, so a slow handler never blows Slack's 3-second ACK window.

Quickstart — connect to Slack in 5 minutes

Socket Mode needs no public URL, no ngrok, no webhooks — the bot dials out to Slack. This is the fastest way to see it work.

1. Create the Slack app

  1. Go to https://api.slack.com/appsCreate New AppFrom a manifest.
  2. Pick your workspace.
  3. Paste the contents of manifest.json (shipped in this repo) → NextCreate.

That's all your scopes, event subscriptions, and Socket Mode configured in one shot.

Name your bot. The name is entirely yours — slink (the library) never hardcodes it, it runs purely off tokens. Before creating, edit these fields in manifest.json (and use your name in place of @slink below):

FieldControls
display_information.namethe app's name in Slack's directory
features.bot_user.display_namethe bot's @handle (what members @mention)
features.slash_commands[].commandthe slash command, e.g. /yourbot

2. Grab two tokens

  1. Bot token — left sidebar → OAuth & PermissionsInstall to WorkspaceAllow → copy the Bot User OAuth Token (starts with xoxb-).
  2. App token — left sidebar → Basic Information → scroll to App-Level TokensGenerate Token and Scopes → add the connections:write scope → Generate → copy the token (starts with xapp-).

3. Export them

export SLACK_BOT_TOKEN=xoxb-your-bot-token
export SLACK_APP_TOKEN=xapp-your-app-token

4. Run your bot

Add it to your application's supervision tree:

children = [
{Slink.SocketMode,
module: MyBot,
app_token: System.fetch_env!("SLACK_APP_TOKEN"),
bot_token: System.fetch_env!("SLACK_BOT_TOKEN")}
]
Supervisor.start_link(children, strategy: :one_for_one)

…or try it right now in IEx, no app needed:

iex -S mix
{:ok, _} =
Slink.SocketMode.start_link(
module: Slink.ExampleBot,
app_token: System.fetch_env!("SLACK_APP_TOKEN"),
bot_token: System.fetch_env!("SLACK_BOT_TOKEN")
)

5. Say hi

In Slack, invite the bot to a channel with /invite @slink, then mention it: @slink hello — it replies 👋. Done.

Want the bot to auto-join channels on boot? Pass join: ["C0123456789"] to Slink.SocketMode. Tune the outbound rate limit with config :slink, :rate_interval_ms, 1_000.

Beyond messages — slash commands, buttons & modals

The same handle_event/2 handles slash commands and interactive components, over either transport. Match on the event type; reply the same way you always do.

def handle_event(%Event{type: :app_mention} = _event, context) do
# Reply with a button: any reply can carry Block Kit `blocks`. `action_id`
# identifies the button when it's clicked; `value` rides along with the click.
reply(context, "Ready to deploy?",
blocks: [
%{
type: "actions",
elements: [
%{
type: "button",
action_id: "deploy",
text: %{type: "plain_text", text: "Deploy"},
value: "prod"
}
]
}
]
)
end
def handle_event(%Event{type: :block_actions} = event, context) do
# The click arrives as its own event. reply/3 posts on the message the button
# is on; action_id/1 says which button, action_value/1 its value.
reply(context, "Deploying #{Event.action_value(event)} 🚀")
end
def handle_event(%Event{type: :slash_commands} = event, context) do
# Slash commands reply through their response_url — reply/3 handles that.
# to: :ephemeral (default) shows only the invoker; to: :channel posts publicly.
reply(context, "running `#{Event.text(event)}`…", to: :channel)
end
def handle_event(%Event{type: :shortcut} = _event, context) do
# Open a modal. Uses the event's trigger_id (valid ~3s), so open promptly.
# open_modal/2 returns {:ok, response} | {:error, reason}; a handler can just
# end with it (a non-{:reply, _} return means "nothing to reply"). Match it
# when you need response["view"]["id"] for a later update_view/3 / push_view/3.
open_modal(context, my_view())
end
def handle_event(%Event{type: :view_submission} = event, _context) do
# A modal submit. Return {:ack, map} to control the modal; this one runs
# synchronously so keep it quick. Anything else closes the modal.
case Event.view_values(event) do
%{"email" => %{"input" => %{"value" => v}}} when v == "" ->
{:ack, %{response_action: "errors", errors: %{"email" => "required"}}}
_ ->
:ok
end
end

You have room to choose how to respond:

Over the Events API, point the app's Interactivity and Slash Commands Request URLs at the same endpoint as events; Slink decodes all three. Slack retries deliveries it doesn't see ACKed — Slink drops the duplicates (Slink.Dedup) so your handler fires once.

Going to production — Events API (HTTP)

For production Slack recommends HTTP over Socket Mode (see the table below). You'll need a public HTTPS endpoint. Run the plug with Bandit:

Bandit.start_link(
plug: {Slink.EventsApi.Plug,
module: MyBot,
signing_secret: System.fetch_env!("SLACK_SIGNING_SECRET"),
bot_token: System.fetch_env!("SLACK_BOT_TOKEN")},
port: 4000
)

…or mount it in an existing Plug/Phoenix router. Pass the secrets as functions — Phoenix evaluates forward options at compile time in production — and mount it where the raw body is still readable (before Plug.Parsers; see the Slink.EventsApi.Plug docs):

forward "/slack/events", to: Slink.EventsApi.Plug,
init_opts: [module: MyBot,
signing_secret: fn -> System.fetch_env!("SLACK_SIGNING_SECRET") end,
bot_token: fn -> System.fetch_env!("SLACK_BOT_TOKEN") end]

Then in the app's manifest/settings: set "socket_mode_enabled": false, add your public URL as the Request URL under Event Subscriptions (https://your-host/slack/events), and copy the Signing Secret from Basic Information into SLACK_SIGNING_SECRET. Slink answers Slack's url_verification handshake and verifies every request's signature automatically.

The same MyBot works unchanged across both transports.

Transport choice, per Slack's own guidance

Socket ModeEvents API (HTTP)
Public URL requiredNoYes
Slack recommends fordevelopment, internal, behind-firewallproduction, reliability, scale
Marketplace / distributed apps✗ not allowed✓ required
Concurrencycapped at 10 connections/appscales horizontally

Telemetry

Slink emits :telemetry events you can attach to for logging or metrics. All carry %{system_time: System.system_time()} as the measurement:

EventMetadataWhen
[:slink, :event, :received]%{type:, transport:, module:}an event arrives, before dispatch
[:slink, :socket, :connected]%{module:}the Socket Mode WebSocket handshake completes
[:slink, :socket, :disconnected]%{module:}a live Socket Mode connection drops

Roadmap

Planned, non-blocking capability work lives in ROADMAP.md.

Development

mix deps.get
mix test

License

MIT — see LICENSE.