Skip to main content
Channel plugin · listing tai42/channel-slack

Install

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

The CHANNEL_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 the CHANNEL_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’s our_identity is the bot user id; client_address is the Slack conversation id:
In Studio, pick slack as the route’s Channel; the Our identity field then shows this hint.

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 ts as the medium message id. A message with several files is bridged as one record per file, and every record carries the same ts.
  • resolve_address returns CHANNEL_SLACK_BOT_USER_ID and 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 the channel from the chat.postMessage response, the conversation id the events door passes for that participant, and the message ts.

Replies

A reply in a thread carries thread_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/Socket user_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

An ask 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: time field whose timeOf names 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 inputMode is numeric, decimal or tel renders as a plain text input; only email and url draw a distinct Slack text element. inputMode selects the control, it never validates — the value is always the string typed.
  • Chips. Slack has no chip input, so control: chips draws 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/minLength on a string is drawn as Slack’s max_length/min_length on both the single-line plain text input and the multi-line textarea (control: textarea), so Slack shows and enforces the maximum. A maxLength above Slack’s 3000 limit is refused loudly at delivery, never truncated. Slack’s email/url text 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’s button_label (else — for a form with no pages — the caller’s wording.form.submit, else Submit) — a non-terminal page’s label is not drawn; the close reads wording.form.cancel (else Cancel) and the title reads wording.form.title (else Please respond), each capped at 24 characters and read off the record on open and every re-render. wording.form.back draws nothing (one surface). A wording.choice.label and a non-form ask’s wording.answer controls draw nothing on Slack (inline choices, a text-link Tier-1 ask).
  • Field placeholders. A field’s wording.form.fields[name].choice_label draws as the placeholder of 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_label and false_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) and wording.lines.deadline ({deadline} filled with the ISO instant); a Tier-2 ask composes wording.lines.reply and the deadline line, and lists its choices through wording.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 own private_metadata, so it reaches the person even after the record is gone; a line that pushes private_metadata past 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 reads wording.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 as FormReactionError, 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