Skip to content

Agent-authored Slack Block Kit components in respond_to_user - #23

Merged
samkim merged 4 commits into
mainfrom
worktree-slack-blockkit-components
Oct 14, 2026
Merged

samkim merged 4 commits into
mainfrom
worktree-slack-blockkit-components

Conversation

@josephschorr

Copy link
Copy Markdown
Member

What

Lets an agent compose presentational Slack Block Kit layout in its replies — not just a markdown string. The agent passes a blocks array on respond_to_user; the Slack kind renders it as the message body, with the existing text field 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 on channelkinds.Kind (Slack opts in). Flows to ChannelBinding.Capabilities → respond_to_user, exactly like "markdown".
  • Two optional interfaces on the kind (mirroring TextFormatter): ComponentFormatter (model-facing authoring instructions, injected into the blocks schema description) and ComponentValidator (ValidateComponents + ComponentsPlainText). The runner already blank-imports the Slack kind, so both are reachable in-process.
  • Validate before publish. respond_to_user calls the kind's validator synchronously; a bad payload returns a precise, model-facing IsError (no envelope published) instead of a silent post-time degrade to plain text.
  • Transport. New OutboundUserMessagePayload.Components json.RawMessage; the Slack sender posts it via MsgOptionBlocks with text as fallback, keeping the existing blocks→plaintext degrade net.
  • Egress safety. The info-leakage gate measures block text too (via ComponentsPlainText), so content can't bypass the gate by riding in blocks.

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/@everyone broadcasts. 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 a title/rich_text_title; charts need a title plus per-type segments or series + axis_config.categories).

slack-go upgrade

Bumps github.com/slack-go/slack v0.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 was GetUserInfoContext becoming 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

  • Table-driven validator tests: one row per accept/reject rule (each block type, each interactive/image/broadcast rejection, structural limits, chart/container shape requirements).
  • respond_to_user tests against the real Slack validator: schema exposes blocks only under the capability; validation failure → IsError, no publish; success carries Components; a leakage-gate test proves block-only content is still measured.
  • Slack sender render + degrade tests.
  • Exercised end-to-end on a live Slack workspace (charts, tables, cards, containers, rich_text, markdown all render; rejections surface clean errors).

Follow-ups (tracked, not in this PR)

  • Surface an upstream Slack invalid_blocks back 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.
  • The channelsd silence-watchdog counts outbound-message silence, not internal tool-call progress, so a long internal run can read as a false "stall."

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.
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
openagentprimitives Ready Ready Preview Oct 13, 2026 9:39pm UTC

Request Review

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment thread pkg/agent/tool/meta/respond.go Outdated
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},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@samkim

samkim commented Oct 13, 2026

Copy link
Copy Markdown
Member

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

  1. Leakage-gate projection drops link URLs. A rich_text link's URL was dropped whenever the link had display text, and task_card source URLs were never projected, so a secret in a URL got past the gate. Links now project as <url|label> (messagecontent.go).
  2. pt stripping could rejoin a blocked token after validation. "<!chan" + pt("nel>") passed the broadcast guard and then published as <!channel>. The validator now checks the stripped blocks that actually publish. The tagged copy still feeds the leakage gate (respond.go).

Slack UI support

  1. Mention hint missing on the streaming path. The @-mention me to reply footer was only added on the chat.postMessage path. Both render paths now share one componentBodyBlocks helper.
  2. Mention hint dropped at 50 blocks. The validator now caps agent blocks at 49, keeping one of Slack's 50 slots for the footer.
  3. Limits counted in bytes. Header, section, field and markdown limits now count characters (runes), so non-Latin text isn't rejected well under the limit.
  4. Per-message limits not enforced. At most 2 charts and 1 table per message, counted inside containers too, and at most 100 rows and 20 columns per table.
  5. Empty blocks accepted. An empty header, section, context, markdown or table block is now rejected up front with a clear error, instead of failing at post time and degrading to plain text.
  6. Plain-text retry layout. The mention hint now comes last, and each table row stays on one line with cells separated by |.

Cleanup

  1. Duplicated pt-stripping code. The JSON pt-stripper now lives in one place, toolenvelope.StripPtJSON. It keeps UseNumber so large numbers don't lose precision, and both the runner and respond_to_user use it.
  2. Duplicated Slack limits. The validator's block and section caps now come from the sender's constants.

Tests

  • New test cases for each fix. Each one fails against 8939347 and passes with this commit.
  • mage test:unit passes.
  • ⚠️ mage test:integration and mage test:e2e were not run, because Docker wasn't available on my machine. They need to pass before merge.

Behavior changes to note

  • Agents can send at most 49 blocks per message, down from 50. The authoring instructions now say 49.
  • Inbound transcripts, which reuse the same flattening code, now show links as <url|label> and keep table rows on one line each.

@samkim
samkim merged commit c805bba into main Oct 14, 2026
9 checks passed
@samkim
samkim deleted the worktree-slack-blockkit-components branch October 14, 2026 16:18

This branch was successfully deployed

1 active deployment
Preview — 038ab06b Deployed Oct 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants