Repository navigation
Agent-authored Slack Block Kit components in respond_to_user - #23
Conversation
Let the agent compose presentational Block Kit layout in Slack replies via a new `blocks` field on respond_to_user, gated by a "components" channel capability. A channel kind opts in by implementing the new ComponentFormatter and ComponentValidator interfaces (channel-neutral seam, Slack-native content): respond_to_user injects the kind authoring instructions into the blocks schema, validates synchronously in the runner before publish — returning a precise, model-facing error instead of a post-time degrade — and carries the validated blocks on OutboundUserMessagePayload.Components. The Slack sender renders them as the message body with the text field as the notification/degrade fallback, and the info-leakage egress gate measures block text via ComponentsPlainText. Supported read-only blocks: header, section (no accessory), divider, context, rich_text, markdown, table, data_visualization, card (no actions), container, plan, task_card. Interactive blocks/elements, images (deferred), and @channel/@here/@everyone broadcasts are rejected with precise errors. Chart and container payload shapes are validated to Slack requirements verified against the live API (container needs a title; charts need per-type segments/series + axis_config). Upgrade slack-go v0.23.1 -> v0.30.1 for typed support of the newer blocks, adapting the variadic GetUserInfoContext signature change.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| sessRef := sess.Namespace + "/" + sess.Name | ||
| includeSettings := s.settingsSeen == nil || !s.settingsSeen.saw(sessRef) | ||
| blocks := s.buildUserMessageBlocks(ctx, sess, pl.Text, sessRef, includeSettings, logger) | ||
| blocks := s.buildUserMessageBlocks(ctx, sess, pl.Text, pl.Components, sessRef, includeSettings, logger) |
There was a problem hiding this comment.
Components silently dropped on the streaming-bubble path. When streamSink.ConsumeFinalTarget returns a bubble ts (the normal channel-thread case), Send returns from the chat.update branch (~L295) using only MsgOptionText(responseReadyEmoji+" "+pl.Text), so this buildUserMessageBlocks(..., pl.Components, ...) call is never reached. The agent's table/chart/card is lost and the user sees only the "concise summary" fallback, with no log. Pass the component blocks to UpdateMessageContext as well (or skip the bubble update when len(pl.Components) > 0).
There was a problem hiding this comment.
Fixed — the chat.update (streaming-bubble) branch now renders pl.Components as blocks, with the text kept as the notification/degrade fallback. If Slack rejects the blocks (isUnsupportedBlocksErr), it retries the bubble update as plain text so the reply still lands.
| blocks := s.buildUserMessageBlocks(ctx, sess, pl.Text, sessRef, includeSettings, logger) | ||
| blocks := s.buildUserMessageBlocks(ctx, sess, pl.Text, pl.Components, sessRef, includeSettings, logger) | ||
| opts := []slackapi.MsgOption{ | ||
| slackapi.MsgOptionText(pl.Text, false), // plain-text fallback for push notifications |
There was a problem hiding this comment.
Components also dropped when attachments are present. The len(pl.Attachments) > 0 branch (~L264) goes to sendWithAttachments, which posts only pl.Text as the root/InitialComment. A reply with blocks plus attached delivers the files and the short summary, and the blocks never render. Either post the blocks as a message ahead of the uploads, or reject the blocks+attached combination in respond_to_user so the model gets a correctable error.
There was a problem hiding this comment.
Went with the reject option — respond_to_user now returns a correctable error when both blocks and attached are set, instead of silently dropping the blocks on the attachment path. Full blocks+files-in-one-reply support is deferred to a follow-up.
| )) | ||
| if len(components) == 0 { | ||
| blocks = append(blocks, textSectionBlocks(text)...) | ||
| } else if agent := parseComponentBlocks(components); len(agent) > 0 { |
There was a problem hiding this comment.
Degraded retry loses the block content. When blocks are the body, the agent is told to keep text to a concise summary. If Slack still rejects the payload (invalid_blocks from a rule the validator doesn't cover, such as a mrkdwn header, a section with neither text nor fields, an empty context, more than 2 charts, or the 12k cumulative markdown limit), the retry at ~L372 posts only pl.Text, so most of the reply is gone. Retry first with textSectionBlocks(text + "\n\n" + flattenBlocks(parsed)), or with the ComponentsPlainText projection, rather than the summary alone.
There was a problem hiding this comment.
Fixed — added a degradedPlainText(pl) helper that appends the components' flattenBlocks projection to text, and both degrade retries (chat.postMessage and the streaming-bubble chat.update) now use it. So a Slack invalid_blocks rejection falls back to the summary plus the block content instead of the summary alone. (Success-path notification fallback stays the concise text.)
| if err := t.cfg.EnvelopeSigner.PublishOut(publish, sess.Namespace, sess.Name, | ||
| channelevents.KindUserMessage, | ||
| channelevents.OutboundUserMessagePayload{Text: outText, Attachments: attachments}, | ||
| channelevents.OutboundUserMessagePayload{Text: outText, Attachments: attachments, Components: in.Blocks}, |
There was a problem hiding this comment.
pt untrusted markup is stripped from text but not from blocks. outText goes through toolenvelope.StripPt, but in.Blocks is published raw. Any pt-untrusted envelope the model copies into a section, markdown, or table cell renders verbatim to Slack users. The gate also sees the tags in gateText (block text is appended to tagged text), so tagged spans in blocks may get the tag-aware treatment instead of the coarse check the comment describes. Strip pt markup from string fields in the blocks before publishing, or reject blocks that contain it.
There was a problem hiding this comment.
Fixed — added stripPtFromBlocks, which decodes the block JSON, walks every string leaf, and StripPts it (matching the text path: strip the markers, keep the content). Operating on decoded strings is required since pt markers are quote-escaped inside JSON values. It runs after the leakage gate, so the gate still measures the tagged projection, and the published blocks carry no markup. Also corrected the stale comment that claimed block text is always untagged.
| add(flattenTableCell(cell)) | ||
| } | ||
| } | ||
| case *slackapi.DataVisualizationBlock: |
There was a problem hiding this comment.
Leakage-gate bypass through chart data. For data_visualization, flattenBlocks projects only Title. Pie segments[].label, series name/data[].label, and axis_config.categories are free-form agent strings that render in Slack but never reach the LeakageGate through ComponentsPlainText. An agent (or an injected prompt) can move blockable content into chart labels and bypass the egress check. Project every label, name, and category string too.
There was a problem hiding this comment.
Fixed — added flattenDataViz; the data_visualization case now projects every free-form string (pie segment labels, series names, data-point labels, axis categories, and x/y axis labels), not just the title, so ComponentsPlainText → flattenBlocks feeds all of it to the leakage gate. Numeric values are skipped. (It also improves inbound chart rendering in the transcript, since messageText uses the same flattenBlocks.)
| slackapi.NewTextBlockObject("mrkdwn", chunk, false, false), | ||
| nil, nil, | ||
| )) | ||
| if len(components) == 0 { |
There was a problem hiding this comment.
Back-compat: the mention_only footer disappears. maybeAppendMentionFooter appends the @-mention hint to pl.Text (~L246). When components are present they replace the text sections, and the text is used only as the notification fallback, so adopted (mention_only) threads no longer show the hint telling users to @-mention the bot. Users reply bare and nothing routes. Append the footer as a trailing context/section block when components are the body (headroom permitting).
There was a problem hiding this comment.
Fixed — when components are the body, buildUserMessageBlocks now re-adds the mention_only hint as a trailing context block, ahead of the clamp line and settings button so it wins the last block slot under tight headroom. Gated on the components actually rendering as the body (not the parse-failure fallback, where the text sections already carry the hint), so it never doubles up.
| // fallback; a kind that does not support components ignores it. Empty means | ||
| // a text-only (or text+attachments) reply. Opaque to channelsd — format is | ||
| // a kind concern, never an envelope one. | ||
| Components json.RawMessage `json:"components,omitempty"` |
There was a problem hiding this comment.
Back-compat under version skew. The runner advertises blocks and tells the model to keep text to a concise summary, but an older channelsd (during a rolling upgrade) unmarshals into a struct without Components, drops the field, and posts only the summary. The runner gets no signal and the content is lost. Consider gating the advertisement on the deployed channelsd's version or capability, or documenting that the runner and channelsd must upgrade together.
There was a problem hiding this comment.
There's no runner↔channelsd capability handshake today and the components token is static in the slack Kind, so I documented the co-upgrade expectation on the Components field instead: an older channelsd degrades to the text summary (a self-contained fallback the model is required to fill), not a lost reply. Building real capability negotiation is a larger mechanism — I've flagged it as a follow-up rather than half-building it here. Happy to take that on in this PR if you'd prefer.
| // no envelope on the wire, exactly like the attachment-resolution guard | ||
| // below. Only the kind knows its component format, so the kind's validator | ||
| // (resolved from the outbound binding at construction) is the authority. | ||
| if len(in.Blocks) > 0 { |
There was a problem hiding this comment.
"blocks": [] / "blocks": null now fail the call. len(in.Blocks) > 0 is true for the raw bytes [] and null. On a non-components channel the model gets "blocks are not supported", and on Slack it gets "must contain at least one block" or "must be a JSON array". Before this change these were ignored. Models often send empty optional arrays, so a plain-text reply now errors and costs a retry turn. Treat trimmed null/[] as absent.
There was a problem hiding this comment.
Fixed — respond_to_user normalizes blocks right after parsing via normalizeOptionalBlocks: a trimmed ""/null/[] becomes absent (nil), so an empty optional array no longer errors a plain-text reply. The single normalization covers every downstream check (not-supported guard, blocks+attached combo, validator, leakage projection, strip/publish, log flag), since they all key off len(in.Blocks) > 0.
| // button. | ||
| if includeSettings { | ||
| // button. Headroom-guarded, as with the clamp line above. | ||
| if includeSettings && len(blocks) < slackMaxBlocksPerMessage { |
There was a problem hiding this comment.
Show-settings button can be suppressed while its one-shot guard is consumed. With a 50-block agent body, this headroom guard drops the button, but Send still runs settingsSeen.mark(sessRef) (~L385) because includeSettings && usedBlocks holds. The session never shows the first-message settings button. The clamp-warning line is dropped silently the same way. Return whether the button was appended and mark only then, or reserve headroom by capping agent blocks at 48/49.
There was a problem hiding this comment.
Fixed — buildUserMessageBlocks now returns whether the settings button was actually appended, and Send consumes the one-shot (settingsSeen.mark) only when it was appended and the block post succeeded. So a full 50-block first reply no longer burns the one-shot; the button shows on the first later reply with room. Chose this over capping agent blocks at 48/49 (which would steal slots from every reply), and added an INFO log when the button is dropped for headroom so it isn't silent. Covered by a new TestBuildUserMessageBlocks_SettingsButton_SuppressedWhenNoHeadroom.
| // containsBroadcastMention reports whether text carries a Slack broadcast span. | ||
| // Slack writes these as <!channel>, <!here>, or <!everyone> (optionally with a | ||
| // |label), so matching the opening token catches every form. | ||
| func containsBroadcastMention(text string) bool { |
There was a problem hiding this comment.
Usergroup pings are not blocked. containsBroadcastMention checks only <!channel, <!here, <!everyone. <!subteam^S123> (a usergroup mention that notifies every member) passes, as does the rich_text usergroup element in rich_text, table, and task_card content. That defeats the stated no-mass-notification guard. Add <!subteam^ and the usergroup rich-text element to the checks.
There was a problem hiding this comment.
Fixed — added <!subteam^ to containsBroadcastMention (catches the text form across every block type) and a RichTextSectionUserGroupElement case to the rich-text walk (covers rich_text blocks, rich_text table cells, and task_card details/output). Updated the three error strings and the model-facing instructions to name usergroup pings, and added tests for both the <!subteam^…> text form and the rich_text usergroup element. Scope note: broadcast/usergroup validation of chart labels (segments/series/categories) is a separate pre-existing gap — comment 5 closed the leakage-gate projection for those, but validateDataViz still only checks the title; happy to tighten that too if you'd like.
There was a problem hiding this comment.
Followed up on the chart-label gap I flagged: validateDataViz now runs checkBroadcast over every free-form chart string — pie segment labels, series names, data-point labels, axis categories, and x/y axis labels — not just the title, so a <!channel>/<!subteam^…> can't slip past validation through a chart label. The checked set mirrors flattenDataViz, keeping validation and the leakage-gate projection in sync. Added test cases (pie segment, bar series name, axis category). Pushed as 8939347.
Review feedback from @samkim: - Render components on the streaming-bubble (chat.update) path; text-only fallback on block rejection — previously dropped silently. - Reject blocks+attached in respond_to_user with a correctable error instead of dropping the blocks on the attachment path. - Degrade retries (postMessage + bubble) fall back to text + flattenBlocks projection via degradedPlainText, not the bare summary. - Strip pt-untrusted markup from published blocks (stripPtFromBlocks), mirroring the text path; strip runs after the leakage gate. - Project all chart strings (segment/series/data-point labels, axis categories, x/y labels) via flattenDataViz so the leakage gate sees them. - Re-add the mention_only footer as a context block when components are the body (headroom-prioritized). - Document the runner<->channelsd co-upgrade expectation on the Components field (no capability handshake today; old channelsd degrades to text). - Treat "blocks": []/null as absent (normalizeOptionalBlocks) so empty optional arrays don't error a plain-text reply. - Consume the Show-settings one-shot only when the button was actually appended and the block post succeeded; INFO log on headroom drop. - Block usergroup pings (<!subteam^...> text form + rich_text usergroup element) in the no-mass-notification guard.
Follow-up to the #10 review thread: validateDataViz only ran the no-mass-notification guard on the chart title. Segment labels, series names, data-point labels, axis categories, and x/y axis labels are free-form strings that render in Slack and easily fit a <!subteam^S…> or <!channel> span, so a mass-ping could slip past validation through a chart label. Now checkBroadcast runs over every one of those strings — the same set flattenDataViz projects to the leakage gate, keeping validation and projection in sync.
Security:
- Validate the pt-STRIPPED blocks, not the tagged ones. Stripping can
rejoin a token the markers split ("<!chan" + pt("nel>") publishes as
"<!channel>"), so the broadcast guard and limits now judge what a human
actually sees. The tagged copy still feeds the leakage gate.
- Project link URLs (rich_text link elements, task_card sources) into the
leakage-gate text as <url|label>, so a URL carrying a secret is measured
rather than hidden behind its label.
Slack UI:
- Render the mention_only "@-mention me to reply" footer on the streaming
chat.update path too, via one componentBodyBlocks helper shared by both
render paths.
- Reserve a block slot for that footer: agents get 49 of Slack's 50, so a
maximal body can no longer silently drop it.
- Count every text limit in characters (runes), not bytes, so non-Latin
text is no longer rejected well under Slack's limits.
- Enforce per-message chart (2) and table (1) counts, containers
included, plus table row (100) and column (20) caps.
- Reject structurally empty header/section/context/markdown/table blocks
that Slack would refuse, turning a post-time degrade into a correctable
runner-side error.
- In the degraded plain-text retry, put the mention footer last and keep
table rows on one line each (cells " | "-separated).
Cleanup:
- Move the JSON pt-stripper into toolenvelope.StripPtJSON (keeping
UseNumber) and use it from both the runner's arg strip and
respond_to_user, replacing the diverged copy.
- Derive the validator's block and section caps from the sender's
constants so the two cannot drift.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Re-review fixes (038ab06)I re-reviewed the PR after 1dc48bb and 8939347. Most of the earlier fixes held up. This commit closes the 10 remaining findings, including two fixes that were only partly applied before. Security
Slack UI support
Cleanup
Tests
Behavior changes to note
|
What
Lets an agent compose presentational Slack Block Kit layout in its replies — not just a markdown string. The agent passes a
blocksarray onrespond_to_user; the Slack kind renders it as the message body, with the existingtextfield as the notification preview and degrade fallback.How
Channel-neutral seam, Slack-native content — no
kind == "slack"branch leaks into the agent tool:"components"capability onchannelkinds.Kind(Slack opts in). Flows toChannelBinding.Capabilities→respond_to_user, exactly like"markdown".TextFormatter):ComponentFormatter(model-facing authoring instructions, injected into theblocksschema description) andComponentValidator(ValidateComponents+ComponentsPlainText). The runner already blank-imports the Slack kind, so both are reachable in-process.respond_to_usercalls the kind's validator synchronously; a bad payload returns a precise, model-facingIsError(no envelope published) instead of a silent post-time degrade to plain text.OutboundUserMessagePayload.Components json.RawMessage; the Slack sender posts it viaMsgOptionBlockswithtextas fallback, keeping the existing blocks→plaintext degrade net.ComponentsPlainText), so content can't bypass the gate by riding inblocks.Supported blocks (read-only / presentation-only)
header,section(no accessory),divider,context,rich_text,markdown,table,data_visualization(line/bar/area/pie charts),card(no actions),container,plan,task_card.Rejected with precise, correctable errors: interactive blocks/elements (
actions,input,context_actions,data_table,carousel, section accessories, card actions),alert(modals-only), images/icons (deferred — see Scope), and@channel/@here/@everyonebroadcasts. Chart and container payload shapes are validated to Slack's actual requirements — verified against the live API — so the agent gets a clean error rather than a degrade (container needs atitle/rich_text_title; charts need atitleplus per-typesegmentsorseries+axis_config.categories).slack-go upgrade
Bumps
github.com/slack-go/slackv0.23.1 → v0.30.1 for typed support of the newer blocks (data_visualization,table,card,container,markdown,plan,task_card, …). The only breaking change wasGetUserInfoContextbecoming variadic; the Slack wrapper interfaces and test fakes are adapted to match.Scope
Slice 1 is presentational only. Interactivity (buttons/menus routing clicks back into the session) is out of scope. Images are deferred to Slice 2 (artifact-backed — resolve a session-owned artifact handle rather than an arbitrary URL).
Testing
respond_to_usertests against the real Slack validator: schema exposesblocksonly under the capability; validation failure →IsError, no publish; success carriesComponents; a leakage-gate test proves block-only content is still measured.Follow-ups (tracked, not in this PR)
invalid_blocksback to the agent as a system note for any future/unknown block that passes our validator but Slack still rejects (channelsd → session feedback), instead of only degrading.