tai42-channel-slack
A Slack channel plugin for the TAI ecosystem. ask(..., channel="slack")
posts the question to a configured Slack channel via chat.postMessage; the
human replies in the message's thread; the Slack Events API delivers the
reply to this plugin's inbound door, which verifies the request signature and
forwards the typed answer to the interaction's public callback URL — so a tool
blocked on a question resumes with a real human answer, out-of-band.
The TAI ecosystem
TAI is an open-source runtime for MCP tools, agents, and workflows. A channel
is a registered deliverer that pushes an interaction question to a human on a
specific medium and bridges the reply back into the interactions store. This
package is one such deliverer (Slack); siblings back the same contract with
Telegram or Twilio (SMS/WhatsApp). The ecosystem is open-ended: any package can
back the same contract, so this repo is this channel's own full doc home, and
the documentation site covers the platform-level story:
- Interactions concept: https://tai42.ai/concepts/interactions
- Build a channel plugin (author guide): https://tai42.ai/guides/authors/channel
- Ecosystem catalog: https://tai42.ai/reference/catalog
Its only tai-* dependencies are tai42-contract (the Channel protocol,
ChannelDelivery, ChannelDeliveryError, and the tai42_app handle) and
tai42-kit[redis] (HttpxClient, RedisClient, TaiBaseSettings, and the
settings cache). Beyond those it depends on httpx, starlette, and
pydantic / pydantic-settings — the whole Slack surface is two HTTPS POSTs
plus stdlib hmac; no slack_sdk, no Bolt.
Install
Requires Python 3.13+. Install from PyPI into the environment that runs the server:
uv add tai42-channel-slack
Or from source — clone this repo and add it as an editable dependency; the
tai42-* dependencies resolve in-tree from the workspace.
git clone https://github.com/tai42ai/tai42 # next to your app checkout
cd /path/to/your/app
uv add --editable ../tai42/plugins/channel-slack
Discovery
The skeleton discovers this plugin through the manifest's channel_modules
field — at app load it imports every module under each named package, and
tai42_channel_slack.register registers the "slack" channel, the Events API
inbound route POST /api/channels/slack/inbound, and the interactivity route
POST /api/channels/slack/interactive as a side-effect. A bare
import tai42_channel_slack registers nothing (library use).
channel_modules: [tai42_channel_slack]
ask(..., channel="slack") then selects it by name.
Configuration
Settings are read from the CHANNEL_SLACK_ environment group (see
SlackSettings / SlackRedisSettings). Credentials are operator-bound
environment configuration — never LLM-visible tool parameters. The recipient
is resolved per question: a caller may request one (ask(..., recipient=...)), and the plugin sends to it only if it is on the operator
allowlist — an unlisted recipient fails loudly, nothing is sent; a question
without a requested recipient goes to the operator default.
| Env var | Meaning |
|---|---|
CHANNEL_SLACK_BOT_TOKEN |
bot token, xoxb-…, scope chat:write (SecretStr) |
CHANNEL_SLACK_SIGNING_SECRET |
Events API signing secret (SecretStr) |
CHANNEL_SLACK_ALLOWED_RECIPIENTS |
whitelist of channel/DM ids a caller may request — comma-separated or a JSON list; empty rejects every requested recipient |
CHANNEL_SLACK_DEFAULT_RECIPIENT |
channel/DM id (C…/D…) used when the caller requests none |
CHANNEL_SLACK_REDIS_URL |
correlation store, e.g. redis://redis:6379/0 |
CHANNEL_SLACK_HTTP_TIMEOUT_SECONDS |
outbound HTTP budget, default 30 |
All credential/target fields default to unset, so importing the package never demands configuration; a delivery or inbound event against an unconfigured channel raises loudly, naming the missing env var. An unset or empty signing secret fails CLOSED — the inbound door raises rather than ever verifying against a forgeable key.
Slack app setup
All links: https://docs.slack.dev
- Create a Slack app; install it to the workspace with bot scope
chat:write(pluschannels:historyfor a public channel target orim:historyfor a DM target); copy the bot token intoCHANNEL_SLACK_BOT_TOKEN. - Copy the app's signing secret into
CHANNEL_SLACK_SIGNING_SECRET. - Event Subscriptions → enable, Request URL =
{INTERACTIONS_PUBLIC_BASE_URL}/api/channels/slack/inbound(the app must already be running so the signedurl_verificationhandshake succeeds), and subscribe to bot eventsmessage.channels(and/ormessage.im). - Interactivity & Shortcuts → enable, Request URL =
{INTERACTIONS_PUBLIC_BASE_URL}/api/channels/slack/interactive— required forformquestions, whose modal is opened and submitted through this door (same signing secret, verified the same way). - Invite the bot to every target channel; put the fallback channel id in
CHANNEL_SLACK_DEFAULT_RECIPIENTand list the ids callers may request inCHANNEL_SLACK_ALLOWED_RECIPIENTS. - There is no boot-time registration call — the dashboard Request URLs are the registration.
How an answer travels
- A tool calls
ask(question, channel="slack", ...). The runtime persists the interaction, mints a single-use callback ticket, and hands the plugin aChannelDelivery. SlackChannel.deliverresolves the recipient — the caller-requested id if it is onCHANNEL_SLACK_ALLOWED_RECIPIENTS(unlisted refuses loudly, nothing sent), elseCHANNEL_SLACK_DEFAULT_RECIPIENT— and posts the question there viachat.postMessage. Success is the JSONok: truefield — Slack answers HTTP 200 even for failed sends, so the status code is never the signal; any failure raisesChannelDeliveryErrorloudly. One send attempt, no retry (chat.postMessageoffers no idempotency key).- For
textandselectquestions the message says to reply in the thread and shows the deadline; the response'stsis stored in Redis asts → callback_urlwith TTL = the question's remaining budget. Forconfirmandexternalquestions the callback URL rides the message as a plain link instead — the human answers through the callback door directly, and no correlation state exists. Forformquestions the message carries a Fill form button; before it is sent, a form record ({callback_url, schema, question, timeout_at}) is stored in Redis underchannel:slack:form:{interaction_id}with TTL = the remaining budget (released if the send fails). formquestions. The button click reaches the interactivity doorPOST /api/channels/slack/interactiveas ablock_actionspayload; the door verifies the signature, peeks the form record, and opens a Block Kit modal (views.open) built from the schema — one input per property (see Form capabilities below). On submit, aview_submissionpayload arrives; the door coerces the state to the schema's JSON types and forwards{"answer": {...}}to the stored callback URL. A door2xxcloses the modal and drops the record; a400shows the door's message on the form (record kept); a404shows an expired notice (record dropped).- The human replies in the thread. The Slack Events API POSTs the event to
POST /api/channels/slack/inbound, which reads a bounded body, verifies theX-Slack-Signaturev0 HMAC over the raw bytes (constant-time, ±300 s replay window, fail-closed), dedupes onevent_id, matches the reply'sthread_tsagainst the correlation store, and forwards{"answer": "<typed text>"}to the stored callback URL. - The public callback door validates the answer against the question's stored
format and records it; the blocked
askreturns it.
Form capabilities
A form question renders as one Block Kit modal. Each schema property maps to a
control, and the per-send layout (pages, data, reactions) drives display
elements, a review step, conditional show/hide and mid-form reactions.
Field controls. string → a plain text input; string with an enum (or a
per-send options list) → a radio group of five choices or fewer, a
select above that; string+format: date → a date picker; string+
format: time → a time picker; string+format: date-time → text (Slack's
date-time picker returns a Unix timestamp, not an RFC 3339 string); an array of
strings → a checkbox group of five choices or fewer, a multi-select above
that, or a multiline text box (one entry per line) when the array declares no
choices; boolean → a Yes/No radio; integer/number → a number input. A
string may also carry an inputMode naming the kind of text it collects: email
→ an email text input, url → a url text input (both Slack text elements
that carry their own built-in check); numeric, decimal and tel keep a plain
text input (see the degrade below); text is the plain text input. A property may
also name a control: chips on a choice and textarea on a string (see below).
Input kinds (the Slack degrade). Slack offers no numeric or phone keyboard hint
on a text element, so numeric, decimal and tel render as a plain text input.
Slack's number_input is deliberately not used for a numeric/decimal string: it
is a number field, not a keyboard on text, and a string answer must arrive exactly as
typed. Only email and url, which Slack draws as dedicated text elements, keep a
distinct control. The value is always the string typed — inputMode draws the
control, it never validates.
Chips (the Slack degrade). Slack has no chip input (its Tag element is
display-only), so a choice with control: chips renders as the shape's default
choice control: a radio group / select for a single string, a checkbox
group / multi-select for an array of strings — the same control the choice
would draw without control.
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 in
the modal. Slack caps max_length at 3000; a declared maxLength above it is
refused loudly at delivery — never silently truncated. Slack's email_text_input and
url_text_input (the email/url input modes) accept no length bound, so a declared
maxLength/minLength is not drawn on them; it stays enforced at the answer door.
Dates (the Slack degrade). Slack's date picker draws no minimum, maximum or disabled dates, and Slack has no range picker. So a declared date bound, an unavailable-days rule, or a range (two date fields — an end field naming its start field, with a day-span bound) renders as plain date pickers with nothing drawn to prevent an out-of-range pick. The constraint is enforced on submit: the platform's one answer check rejects an out-of-range date, an unavailable day, or a range that is out of order or out of span, and the door shows that rejection as an inline error on the offending field (the modal stays open to correct it).
Display elements and the review step. A page's ordered display blocks render
as a header (heading), a section (body), or an image block (an image with no
drawable source degrades to its alt text). A review page renders a generic
readback section of the values entered so far before the submit button. Slack
modals are a single surface with no native multi-step, so pages render as titled
groups and the review step is a final titled group in the one modal (not a
pushed confirmation view).
Conditional fields. A property's visibleWhen predicate is evaluated by the
platform (never a call to the form's consumer): the controlling field dispatches a
block_actions on change and the modal is re-rendered via views.update to show or
hide the dependent field. The predicate is also enforced on submit — a field hidden
by it is dropped from the answer, so a value left behind for a now-hidden field is
never submitted.
Reacting forms. When a form's ask names a reaction handler and declares
reactions triggers, a reacting field dispatches a block_actions on change; the
door runs the handler through the platform's one reaction seam and applies the
returned update by views.update — setting values, replacing a field's choice list,
showing a per-field error (as a context line), and filling a display slot (a
computed total shows as a section). The accumulated reaction state rides the view's
private_metadata, so it survives each re-render. The submitted trigger runs the
handler on view_submission as the consumer's final check: its per-field errors keep
the modal open as inline errors; a clean check lets the answer through. A handler
failure surfaces loudly — a notice to the person plus a logged error — never a stale
or silent value. A page_advanced trigger has no Slack event (the single-surface
modal has no page-advance step), so only field_changed and submitted fire here.
Form buttons and title. The modal's chrome carries the sender's own words, the
platform's as the default, all read from the per-send wording catalogue. The open
button on the chat message reads wording.form.open (else Fill form, cap 75
characters). The modal's submit reads the last page's button_label (the form's
terminal action; a non-terminal page's label is not drawn — Slack renders every page in
one modal with one submit), else — for a form with no pages — the caller's
wording.form.submit, else Submit. The modal's close reads wording.form.cancel
(else Cancel) and its title reads wording.form.title (else Please respond);
each is capped at Slack's 24-character modal-text limit and refused loudly over it.
The whole catalogue rides the form record, so the modal built at click time and every
re-render show the caller's words. wording.form.back draws nothing — the modal is one
surface with no page-back step. A choice list's wording.choice.label and a non-form
question's wording.answer controls draw nothing on Slack: the choices are inline
buttons (no list opener) and a Tier-1 ask is a text link (no button) with no input hint.
Field placeholders and the review line. 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 and draws none, and an absent label leaves Slack's own default prompt.
The review step's empty readback reads wording.form.review_empty (else No answers to
review yet.), within Slack's 3000-character section-text limit.
Message lines and the refusal sentence. A Tier-1 (confirm/external) ask's link
and deadline lines read wording.lines.answer_link ({url} → the callback URL) and
wording.lines.deadline ({deadline} → the ISO instant); a Tier-2 (text/select)
ask composes wording.lines.reply and the deadline line and lists its choices through
wording.lines.options ({options} → the joined list). Each is a plain substitution,
the platform's own line when absent. When the door rejects a modal submission in place,
the modal's inline error reads wording.answer.rejected (its optional {reason} token
→ the door's reason), else the door's reason, else a generic line; the thread-reply
rejection notice draws the same caller sentence through the shared answer ladder, which
also honours the ask's on_mismatch digression policy carried on the correlation record.
Operational notes: answers must be typed in-thread; non-answer traffic (edits, bot echoes, other channels, top-level messages, threads with no pending question) is always acked 2xx so Slack never disables the event subscription; the question message shows the deadline, and the waiting ask surfaces an unanswered question through its own timeout.
Development
uv venv --python 3.13
uv pip install --no-sources --group dev --editable .
uv run --no-sync pytest --cov --cov-report=term-missing
uv run --no-sync ruff check .
uv run --no-sync ruff format --check .
uv run --no-sync pyright
The live integration suite (pytest -m integration) posts a real message to
the configured workspace; it skips cleanly when the CHANNEL_SLACK_*
credentials are absent.
Deferred by design: Socket Mode inbound (needs a managed background task — platform scope; webhook mode needs exactly the public URL the external interactions flow already requires) — the plugin runs in webhook mode only.
License
Apache-2.0. See LICENSE and NOTICE.
Correlation surface (2.0)
Since 2.0 inbound answers resolve through the platform's shared inbound-answer
ladder: the plugin exposes its correlation store over the contract's
CorrelationStore port (reserve / peek / release) plus a transport ack, and the
skeleton owns the forward / retry-in-place / bridge ladder. The plugin-local
pop/restore correlation helpers from 1.x are gone.
Metadata
Release files for tai42-channel-slack 6.0.6
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tai42_channel_slack-6.0.6.tar.gz | 127.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tai42_channel_slack-6.0.6-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 205.4 kB
Release files / tai42_channel_slack-6.0.6.tar.gz
| Download URL | tai42_channel_slack-6.0.6.tar.gz |
|---|---|
| Size | 127.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3ab813e2484971e03da92c93ddfc9ddd1811d7bfd20068f1cb79379e08eb1e4c
|
|
BLAKE2b-256 checksum How to use checksums |
bf8f9b415c80e8a63c5b2329454954d7e2480a6011c2017dc7d0a3b8f9cfc28e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / tai42_channel_slack-6.0.6-py3-none-any.whl
| Download URL | tai42_channel_slack-6.0.6-py3-none-any.whl |
|---|---|
| Size | 77.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
410be44ead5ad6783d241f1240239c9784e561e9b14181e3ea0056ffc353ed00
|
|
BLAKE2b-256 checksum How to use checksums |
6b88c539c53b38524c658823b182d8b28bea8d3626e168eef310321f6a53673b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|