A lightweight Slack bot toolkit.
Slink gives you one event-handling contract and two interchangeable
transports:
Slink.SocketModeβ dials out to Slack over a WebSocket. No public endpoint required. Best for development and internal/behind-firewall apps.Slink.EventsApi.Plugβ aPlugthat receives Slack's HTTP event callbacks. Best for production and distributed apps.
Both transports normalise Slack payloads into a Slink.Event and dispatch it
to your module's handle_event/2. Write your bot once; pick the transport
per environment (Socket Mode in dev, HTTP in prod β which is exactly what
Slack recommends).
Defining a bot
defmodule MyBot do
use Slink
alias Slink.Event
@impl true
def handle_event(%Slink.Event{type: :app_mention} = event, _context) do
# Return a reply and slink sends it (placement: to: :auto by default).
{:reply, "hi <@#{Event.user(event)}> π"}
end
def handle_event(_event, _context), do: :ok
endOr reply imperatively β reply/3 returns :ok, so it can be the last
expression, and to: picks where it lands (:auto, :thread, :channel):
def handle_event(%Slink.Event{type: :app_mention} = event, context) do
reply(context, Event.command(event), to: :channel)
endHandlers that do several things chain the helpers with with β the return
shapes are consistent, so the first {:error, reason} short-circuits. See
the Composing helpers guide.
Running it (Socket Mode)
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)See the module docs for Slink.EventsApi.Plug to run the HTTP transport.
Multiple workspaces
Slink is token-per-request throughout: every Slink.API call takes a token,
the handler context carries the :bot_token, and both transports let you pick
it per workspace. Over Socket Mode you run one client per workspace (see
Slink.SocketMode); over HTTP you pass a :bot_token resolver that receives
the event's team id (see Slink.EventsApi.Plug). So one bot module can serve
many workspaces.
To acquire those tokens as workspaces install your app, use the OAuth
install flow in Slink.OAuth β the consent URL, code exchange, and a
callback plug are done for you. Persisting a token per team is deliberately
yours: bring your own store; Slink routes to whatever token you hand it.
Summary
Callbacks
Invoked for every event Slack delivers, from either transport.
Functions
Whether a bot should start, given its config.
Whether the event happened inside a thread (imported by use Slink).
Whether the bot itself is @-mentioned in the event's text (imported by
use Slink).
Open a modal in response to the interaction or slash command in context
(imported by use Slink).
Reply to the event in context (imported by use Slink). Returns :ok, so a
handler can end with it β no trailing :ok needed
Send a direct message to user, using the bot token from the handler
context (imported by use Slink).
Post a message to channel, using the bot token from the handler context.
Show a status line under the assistant thread of the event in context β
"is thinkingβ¦" while the real answer is prepared (imported by use Slink).
Stream a reply into the event's thread, chunk by chunk (imported by
use Slink).
Replace the message this interaction came from (imported by use Slink).
Show a "working on it" indicator on the triggering message only if the work
is slow, then clear it (imported by use Slink). Returns whatever fun
returns.
Types
@type context() :: Slink.Context.t()
Context passed to handle_event/2. See Slink.Context.
What a handler returns:
:okβ done, no reply.{:reply, text}β slink replies withtextviareply/3with the defaultto: :autoplacement (threaded if the event is in a thread, otherwise inline).{:reply, text, opts}β same, passingoptstoreply/3:to: :thread/to: :channelto force placement, andblocks: [...]/attachments: [...]for rich replies.textis still sent as the notification/fallback Slack shows in previews, so always provide something meaningful.{:ack, map}β only for aview_submission(modal submit):mapis Slack'sresponse_actionreply, e.g.%{response_action: "errors", errors: %{"block" => "β¦"}}to show validation errors, orupdate/pushto swap the modal. This event type runs synchronously, so return promptly. Any other return closes the modal.
Any other value is treated as :ok (no reply).
Callbacks
@callback handle_event(Slink.Event.t(), context()) :: result()
Invoked for every event Slack delivers, from either transport.
Return :ok to do nothing, or {:reply, text} to reply (see result/0).
The transport has already acknowledged the event to Slack before this runs,
so slow work here never risks Slack's 3-second ACK window.
Functions
Whether a bot should start, given its config.
Returns true only when :enabled is truthy and both :app_token and
:bot_token are present. Use it to conditionally add Slink.SocketMode to a
supervision tree, so an app without credentials (or with the bot switched off)
simply doesn't connect:
children =
if Slink.enabled?(config) do
[{Slink.SocketMode, [module: MyBot] ++ config}]
else
[]
endconfig is any keyword list or map (e.g. from Application.get_env/2).
Whether the event happened inside a thread (imported by use Slink).
Accepts either a context (like the other imported helpers) or a bare
Slink.Event. Delegates to Slink.Event.in_thread?/1.
Whether the bot itself is @-mentioned in the event's text (imported by
use Slink).
Unlike matching on :app_mention β a whole event type β this answers the
question for any event carrying text, e.g. a :message in a thread the bot
is following. Compares against context.bot_user_id, which is discovered via
auth.test shortly after the transport starts; returns false while that's
still unknown (and :app_mention events don't need it anyway).
Open a modal in response to the interaction or slash command in context
(imported by use Slink).
Returns {:ok, response} | {:error, reason} β the standard shape for a call
that returns data and can fail. response is Slack's views.open payload, so
response["view"]["id"] is the id you pass to update_view/3 later.
(push_view/3 instead takes a fresh trigger_id from a later interaction
inside the modal, not the opened view's id.) You can also just end a handler
with it: the dispatcher treats a
non-{:reply, β¦}/{:ack, β¦} return as "no reply" (see result/0), so a
bare open_modal(context, view) is fine and no trailing :ok is needed.
Uses the event's trigger_id, which Slack honours for only ~3 seconds, so open
promptly. view is a Block Kit view map.
def handle_event(%Slink.Event{type: :shortcut} = _event, context) do
open_modal(context, my_view())
end
Reply to the event in context (imported by use Slink). Returns :ok, so a
handler can end with it β no trailing :ok needed:
def handle_event(%Slink.Event{type: :app_mention} = event, context) do
reply(context, "on it π")
endThe channel and thread come from context.event (set by the dispatcher), so
no event argument is needed. This works the same for a block_actions
interaction (a button click): the reply lands on the message the button is on.
Where the reply lands is controlled by opts[:to]:
:auto(default) β dynamic: in the thread if the event is in one, otherwise inline in the channel.:threadβ always in a thread: the event's existing thread, or a new one started on the triggering message.:channelβ always inline in the channel timeline, even if the event was inside a thread.:ephemeralβ visible only to the user who triggered the event, and gone on reload. Interactions go through theirresponse_url; plain events (a mention, a message) usechat.postEphemeral.
Every other key in opts is merged into the Slack request body, for rich
replies: blocks: [...] (Block Kit), attachments: [...], an explicit
thread_ts:, etc.
reply(context, "deployed β
", to: :channel, blocks: blocks)For a slash command the reply goes to the command's response_url instead,
with to: :ephemeral (default, only the invoker) or to: :channel.
An interaction with no channel to post into (a button on an ephemeral
message, a message in a channel the bot isn't a member of) falls back to its
response_url β ephemeral to the invoker, or in-channel with to: :channel β
rather than failing. To replace the message a button lives on instead of
posting a new one, see update_original/3.
Send a direct message to user, using the bot token from the handler
context (imported by use Slink).
Opens (or resumes) the DM conversation via conversations.open, then posts
through Slink.Rate like send_message/4. opts merges into the request
body (blocks: etc.). Returns :ok, or {:error, reason} if the DM
couldn't be opened (e.g. the app lacks the im:write scope).
def handle_event(%Slink.Event{type: :team_join} = event, context) do
send_dm(context, Slink.Event.user(event), "welcome aboard π")
end
Post a message to channel, using the bot token from the handler context.
Goes through Slink.Rate so sends are rate-limited per channel (Slack allows
~1/sec/channel). opts β a keyword list or map β is merged into the request
body (e.g. blocks:, thread_ts:), matching reply/3's keyword opts. Returns
:ok. use Slink imports this, so handlers can call it unqualified:
def handle_event(%Slink.Event{type: :app_mention} = event, context) do
send_message(context, Slink.Event.channel(event), "hi")
end
Show a status line under the assistant thread of the event in context β
"is thinkingβ¦" while the real answer is prepared (imported by use Slink).
Wraps Slink.API.set_thread_status/4 with the event's channel and thread.
Pass "" to clear; posting or streaming a reply into the thread clears it
automatically. Returns :ok | {:error, reason}. Needs the assistant:write
scope.
Stream a reply into the event's thread, chunk by chunk (imported by
use Slink).
Built for AI apps: pass any enumerable of text chunks β an LLM token stream,
a Stream, a list β and it renders as one live, progressively-updating
Slack message via chat.startStream / appendStream / stopStream:
def handle_event(%Slink.Event{type: :message} = event, context) do
set_status(context, "is thinkingβ¦")
stream_reply(context, MyLLM.stream(Slink.Event.text(event)))
endChunks are buffered and appended at most every :flush_ms (default
400ms), so a fast token stream doesn't hammer Slack's limits.
Streamed messages are always thread replies: the event's thread, or a new
one on the triggering message (like reply/3 with to: :thread). If the
surface can't stream (the method errors β e.g. the feature isn't enabled for
the app), it degrades to a single chat.postMessage with the full text,
so the reply still arrives.
Returns {:ok, ts} of the streamed (or fallback) message, or
{:error, reason} when both paths failed. The enumerable is fully consumed
either way. Raises ArgumentError for an event with no channel or nothing
to thread under (a global shortcut, a modal submit). A chat.stopStream
failure after a successful stream still returns {:ok, ts} β the message
exists with everything appended β and logs what the failed stop was
carrying (a :finish payload, trailing text).
Options:
:flush_msβ minimum interval between appends (default 400).:startβ extrachat.startStreamparams; streaming into a channel (not the app's DM) requires%{recipient_user_id: ..., recipient_team_id: ...}.:finishβ extrachat.stopStreamparams (e.g.%{blocks: [...]}β blocks are allowed only on the final call).
Replace the message this interaction came from (imported by use Slink).
The canonical "a button click updates its own message" pattern: posts to the
event's response_url with replace_original: true, so it also works on
ephemeral messages and in channels the bot isn't a member of β places
Slink.API.update_message/5 can't reach. opts merges into the body
(blocks: etc.).
def handle_event(%Slink.Event{type: :block_actions} = event, context) do
update_original(context, "deploying #{Event.action_value(event)}β¦")
endReturns :ok, or {:error, :no_response_url} when the event carries none
(only slash commands and message interactions do β not plain events), or the
responder's {:error, reason}.
Show a "working on it" indicator on the triggering message only if the work
is slow, then clear it (imported by use Slink). Returns whatever fun
returns.
Slack has no bot "typingβ¦" indicator for channels, so this reacts to the
event's message with an emoji (default hourglass_flowing_sand β³). To avoid a
pointless flicker on fast replies, fun runs in a task and the reaction is
added only if it's still running after :delay_ms (default 3000ms);
it's always removed once fun finishes β even if it raises. Fast work shows
nothing. So it's safe to wrap any handler:
def handle_event(%Slink.Event{type: :app_mention} = event, context) do
working(context, fn -> reply(context, answer(event)) end)
endOptions:
:delay_msβ how long to wait before showing the indicator (default3000). Use0to show it immediately.:emojiβ reaction name without colons (default"hourglass_flowing_sand").
Best-effort: reaction API errors are ignored so they never break the handler,
and if the event has no message to react to, fun just runs inline. One
caveat: if the app is shut down mid-work (a deploy, a :brutal_kill), the
removal never runs and the reaction can be left on the message.