Channel plugin · listing tai42/channel-slackInstall
Permissions
Provides
slack
Channel — Deliver ask questions to a Slack channel and bridge the threaded reply back.
Bridge a Slack app to an agent, or deliver ask questions into Slack. A
message runs an agent turn and the answer posts back in the same conversation.
Enable it
manifest.yml
Configuration
TheCHANNEL_SLACK_ env group. CHANNEL_SLACK_REDIS_URL (and the inherited
connection-tuning fields) point the plugin-owned correlation store.
CHANNEL_SLACK_BOT_USER_ID is required whenever the channel is loaded. The
inbound door bridges under the deployment’s single bot identity, so the channel’s
startup hook refuses to boot without it, naming the variable — the bot never loops
on its own posts and an inbound event never fails for want of an identity.
Point the Events API at the deployment
The Request URL is not set at boot — the channel’s only startup hook is theCHANNEL_SLACK_BOT_USER_ID check above. Set the Request URL once in your Slack app’s Event
Subscriptions to {public base URL}/api/channels/slack/inbound and subscribe to
the message events the bot should hear. Slack verifies the URL against the
signing secret.
A me_message (a /me … post) and a thread_broadcast (a threaded reply also sent
to the channel) carry the participant’s own text and bridge like a plain message — a
threaded broadcast resolves a pending question when it lands in an allowlisted
recipient. Other subtypes — edits (message_changed / message_deleted),
joins/leaves, bot_message, pins and the message_replied container — carry no
participant utterance and are acknowledged without bridging.
Inbound media
A message that shares files (subtype file_share) bridges one turn per file, so
a message with several files never drops any of them. Each file’s bytes are fetched
from Slack (the bot token authorizes the download and rides the request only) and
ingested into the platform’s served media: the turn carries a typed attachment whose
url is the served /api/interactions/media/<id> reference, alongside the parity
media_* entry-params (media_kind, media_id — the served id, media_mime_type,
media_sha256, media_filename for a document, media_size). The mime major type
resolves the kind (image/video/audio, application/text → document, any
other or missing type → file). The message text rides the first bridged file’s turn;
every other turn gets a [kind] placeholder, so a caption-less share is never
dropped as empty.
Entry params are bounded at the door (at most 16 keys, each value at most 512 characters,
the whole set at most 2048 bytes). Because Slack redelivers a failed webhook, this door
drops rather than refuses: an over-cap value is dropped on its own, an over-bound set is
dropped whole, and the participant’s message still bridges. Every drop is logged at
WARNING (naming the door and the bound, never a value) and counted on
tai_entry_params_dropped_total{door="slack"}.
A fetch that fails temporarily (a Slack 5xx, a timeout, a torn download) makes the
door answer non-2xx so Slack redelivers and the turn is ingested on the retry. A
permanent failure — a missing or unreachable body, a file over the operator’s byte
cap, a disallowed type, or no media store configured — bridges no turn: the
participant gets a single notice (unsupported type, too large, or could not receive) and a platform event records it, while the message’s other files still
bridge.
Create a bridge route
The route’sour_identity is the bot user id; client_address is the Slack
conversation id:
Message ids and addresses
The conversation bridge records every message this channel sends to a participant. These are the ids and addresses it records:- An inbound message passes its
tsas the medium message id. A message with several files is bridged as one record per file, and every record carries the samets. resolve_addressreturnsCHANNEL_SLACK_BOT_USER_IDand the recipient as the caller names it. For a user id that is not the direct-message conversation the message lands in: a completed send reports thechannelfrom thechat.postMessageresponse, the conversation id the events door passes for that participant, and the messagets.
Replies
A reply in a thread carriesthread_ts, the ts of the thread’s parent message; a message
whose thread_ts is its own ts starts the thread and quotes nothing. The channel hands the
parent’s ts to the platform beside the reply, on every file turn of a threaded file share
too, and the platform resolves it against the conversation’s own records into a typed
reply_to ({message_id, status, text}): a tool target reads it in its payload, an agent
target through start_expr. An option-button tap is a reply to the message holding the
buttons. A form modal submission names no message, so it quotes none.
When the reply answers a pending question, the asker receives the same reply_to beside the
unchanged answer, and a tap’s reply_id entry parameter as its params. A thread reply or a
tap that finds no pending question bridges into the conversation with the same quoted id.
Working-on-it signal
Slack shows no bot typing indicator: the Web API exposes none for a bot user (only the RTM/Socketuser_typing frame this plugin does not use), so this channel emits
no working-on-it signal while a turn runs. The answer posts back when it is ready.
Form questions
Anask with answer_format: form posts a Fill form button that opens a Block
Kit modal. Schema properties map to controls — a short enum to a radio group and a
longer one to a select, an array of strings to a checkbox group or multi-select, a
date to a date picker, a boolean to a two-option radio (Yes/No, or the caller’s words), a
number to a number input — and the
per-send layout drives display blocks, a review step, conditional fields and mid-form
reactions. A string field may name an inputMode for the kind of text it collects:
email draws an email text input and url a url text input; a format: email field
draws the email text input too. A property may also name a control: chips on a
choice, textarea on a string.
A field’s declared shape is checked inside the modal: a number’s inclusive
minimum/maximum is drawn as the number input’s min_value/max_value (an
integer’s rounded inward to whole numbers; bounds no value can meet are refused when
the form is asked, on every channel), a text length bound as the text input’s own (below), and every
other shape (pattern, the email shape, an exclusive bound, a date bound or range,
a selection count) by the answer door on submit — the modal never closes on a refusal: the door’s plain
sentence for the failing field (Enter a valid email address) is pinned under that
field (wrapped by the caller’s wording.answer.rejected when set), and the person
corrects it in place.
A field may carry a second line of text (its schema description), drawn as the
input block’s hint under the control; a per-send option may carry one too (its
description), drawn as the option’s description under its label. Each is capped at
Slack’s own limit (an option description at 75 characters) and an over-cap value is
refused loudly at delivery, never truncated.
Slack-specific behaviour (the documented degrades):
- Dates carry no drawable bounds — Slack’s date picker has no min/max/disabled dates and no range control — so a date bound, an unavailable-days rule or a range (two date fields) is enforced on submit and shown as an inline error on the field, not prevented in the picker.
- Range times. A time paired with a range end (a
format: timefield whosetimeOfnames that end) draws as Slack’s time picker (HH:mm, 24-hour, no seconds) right after its date picker, wherever the schema declares it, and a review page reads it back right after its date. An end time before the start time on the same day is enforced on submit and pinned under the end time’s picker. - Input kinds. Slack has no numeric or phone keyboard hint on a text element, so a
string field whose
inputModeisnumeric,decimalortelrenders as a plain text input; onlyemailandurldraw a distinct Slack text element.inputModeselects the control, it never validates — the value is always the string typed. - Chips. Slack has no chip input, so
control: chipsdraws the shape’s default choice control (a radio group/select for a single string, a checkbox group/multi-select for an array of strings). - Text length bounds. A declared
maxLength/minLengthon astringis drawn as Slack’smax_length/min_lengthon both the single-line plain text input and the multi-line textarea (control: textarea), so Slack shows and enforces the maximum. AmaxLengthabove Slack’s 3000 limit is refused loudly at delivery, never truncated. Slack’semail/urltext elements accept no length bound, so a declared bound is not drawn on them — it stays enforced at the answer door. - Buttons and title. The open button reads
wording.form.open(else Fill form, cap 75); the modal’s submit reads the last page’sbutton_label(else — for a form with no pages — the caller’swording.form.submit, else Submit) — a non-terminal page’s label is not drawn; the close readswording.form.cancel(else Cancel) and the title readswording.form.title(else Please respond), each capped at 24 characters and read off the record on open and every re-render.wording.form.backdraws nothing (one surface). Awording.choice.labeland a non-form ask’swording.answercontrols draw nothing on Slack (inline choices, a text-link Tier-1 ask). - Field placeholders. A field’s
wording.form.fields[name].choice_labeldraws as theplaceholderof that field’s select (static_select/multi_static_select), capped at Slack’s 150-character placeholder limit and refused loudly over it; a radio or checkbox group has no placeholder slot, so it draws none. Absent, the select shows Slack’s own default prompt. - Yes/no words. A boolean field’s two radio options read
wording.form.fields[name].true_labelandfalse_label(else Yes / No), each capped at Slack’s 75-character option-text limit and refused loudly at send over it; the review readback shows the same words. - Review line. The review step’s empty readback (before anything is filled) reads
wording.form.review_empty(else No answers to review yet.), capped at Slack’s 3000-character section-text limit. - Message lines. A Tier-1 ask’s link line and deadline line read
wording.lines.answer_link({url}filled with the callback URL) andwording.lines.deadline({deadline}filled with the ISO instant); a Tier-2 ask composeswording.lines.replyand the deadline line, and lists its choices throughwording.lines.options({options}filled with the joined list). Each is a plain substitution; absent, the platform’s own line is used. - Refusal sentence. When the door rejects a modal submission in place, the modal’s inline
error reads
wording.answer.rejected(its optional{reason}token filled with the door’s reason, and left empty when the door gave none), else the door’s reason, else a generic correction line. The thread-reply rejection notice draws the same caller sentence through the shared answer ladder. - Form no longer available. When the form’s question is gone — its record expired before
the submit, or the door reports the ask gone — the modal’s error reads
wording.status.unavailable, else This question is no longer available; it has expired. The line rides the modal’s ownprivate_metadata, so it reaches the person even after the record is gone; a line that pushesprivate_metadatapast Slack’s 3000-character limit is refused at send. - Display and review. A page’s heading/body/image blocks render as
header/section/image; a review page is a final titled readback group in the single modal (Slack has no native multi-step, so no pushed confirmation view). - Conditional fields show/hide by the platform re-rendering the modal
(
views.update) on the controlling field’s change — platform logic, never a call to the form’s consumer; the predicate is also enforced on submit. - Reacting forms route a reacting field’s change (and the submit check) through
the platform’s reaction seam and apply the result with
views.update. A page-advance trigger has no event on Slack’s single-surface modal. When a reaction fails, or returns an update the modal cannot draw, the person readswording.form.update_failed(as a banner on the re-rendered modal, or as the submit’s inline error), else Sorry, this form could not be updated right now. Please try again. Its optional{reason}token takes the reason a reaction raised asFormReactionError, and is left empty for any other failure. A reaction reason is at most 500 characters, so the line with each token filled at that bound must fit Slack’s 3000-character section-text limit; a longer line is refused at send. - Field errors. The modal shows errors per field and draws no
wording.form.error_line; a missing required value is the Slack client’s own check.
See also
- Client conversations — the bridge model and delivery outcomes.
- Conversation bridge — every setting and the authorization model.

