I’ve been building a multi-channel AI agent framework. The idea is simple: an agent shouldn’t care what channel it’s talking through. Ask it for the weather in Slack, you get a native Slack message with Block Kit formatting. Ask the same question in the web UI, you get a rich widget. Same tool, different renderings.

The MCP server returns all the formats at once. The agent framework figures out which channel the user is on and extracts the right one.

Tools stay stable. Channels evolve independently.

I had this working for our web UI using ChatKit widgets and the ui:// embedded resource pattern. Same weather query, rich native rendering.

ChatKit widget showing weather forecast for Toronto in the web UI

Time to add Slack support.

Slack has two options for rich content. The short version: Block Kit is for messages, Work Objects are for entities.

Block Kit is their standard structured message format: buttons, fields, images. It’s good for compact inline content. Current temperature, a quick summary, action buttons.

Block Kit response showing current weather in Toronto with inline formatting

Work Objects are different. They’re those expandable “unfurl” cards you see when someone pastes a link, except you can generate them programmatically. They support custom fields, actions, and a flexpane sidebar that slides open with more detail. Use Block Kit when you want structured content in the message flow. Use Work Objects when you’re representing an entity that users might want to inspect or act on.

Work Object response showing weather forecast for Montreal with expandable card

I built the templates. Wired up the extraction. Called the Slack API.

200 OK.

The message text appeared in the channel. But no Work Object. No rich unfurl. Just plain text.

No error. No warning in the response body. Just… nothing.


This is the part of AI development that doesn’t get talked about. The agent did its job. The MCP server returned the right data. The API said success. But the feature didn’t work.

It’s the bingo machine problem, except the machine is the external API. You watch the pieces fall into place, you get the green light, and then you check the actual output and it’s wrong.

I started debugging. Checked the template hydration. Correct. Checked the JSON structure. Valid. Checked the API call parameters. All there. Re-read the Slack documentation for Work Objects.

Then I noticed something. The Work Objects docs show a different metadata structure than regular Slack message metadata.

Regular message metadata nests entities inside event_payload:

1
2
3
4
5
6
metadata: {
  event_type: "...",
  event_payload: {
    entities: [...]
  }
}

Work Objects put entities at the top level:

1
2
3
metadata: {
  entities: [...]
}

I’d been using the wrong structure. An easy mistake if you’ve worked with Slack’s regular metadata before.

Fixed it. Deployed. Called the API.

200 OK. Still no Work Object.


At this point I added debug logging that captured the full API response, not just the success/failure status. That’s when I found it. Buried in the response:

1
2
3
4
5
6
7
8
9
{
  "ok": true,
  "warning": "invalid_metadata_format",
  "response_metadata": {
    "messages": [
      "missing required field: alt_text (pointer: /metadata/entities/0/entity_payload/attributes/product_icon)"
    ]
  }
}

The API accepted my request. Returned success. And silently dropped the Work Object because I was missing a required alt_text field on the product icon.

This is what I mean about APIs not being designed for agents. A human developer sees that warning in their logs during testing and fixes it. An AI agent declaring “done” after a 200 response has no idea the feature isn’t working. The API’s “helpful” behavior of accepting invalid data and degrading gracefully is exactly the opposite of what automated systems need.

Agents need loud failures. Explicit rejection. “Your metadata is invalid, here’s why, I’m not accepting this request.” Instead, Slack says “sure, no problem” and quietly ignores the parts it doesn’t like.


Once I fixed both issues, the pattern came together.

The MCP server returns multiple embedded resources in a single tool result:

1
2
3
4
5
6
7
8
{
  "content": [
    { "type": "text", "text": "Current weather in Toronto: -5°C" },
    { "type": "resource", "resource": { "uri": "ui://widgets/weather/...", "..." } },
    { "type": "resource", "resource": { "uri": "slack://blocks/weather/...", "..." } },
    { "type": "resource", "resource": { "uri": "slack://work-objects/weather/...", "..." } }
  ]
}

Each resource has a URI scheme that identifies what it is. ui:// for web widgets. slack://blocks/ for Block Kit. slack://work-objects/ for Work Objects. Custom MIME types tell you how to parse them.

flowchart LR Tool[Tool Result] --> UI["ui://widgets/..."] Tool --> Blocks["slack://blocks/..."] Tool --> WorkObj["slack://work-objects/..."] Tool --> Text["text"] UI --> Web["Web UI
(ChatKit widget)"] Blocks --> Slack["Slack
(Block Kit)"] WorkObj --> Slack2["Slack
(Work Object)"] Text --> CLI["CLI / fallback"]

The agent framework checks what channel it’s responding to and extracts the right resource. The extraction logic is straightforward: scan the content array for a resource matching the URI prefix and MIME type you want, then parse the JSON payload.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def extract_resource(message, uri_prefix:, mime_type:)
  mcp_content = message.metadata&.dig("mcp_content")
  return nil unless mcp_content.is_a?(Array)

  resource_item = mcp_content.find do |item|
    next unless item["type"] == "resource"
    resource = item["resource"]
    resource["uri"].to_s.start_with?(uri_prefix) &&
      resource["mimeType"].to_s == mime_type
  end

  return nil unless resource_item
  JSON.parse(resource_item.dig("resource", "text"))
end

Web UI? Extract the ui:// resource, render the widget. Slack? Check for slack://blocks/ first, fall back to slack://work-objects/, or just send plain text if neither exists.

The weather tool doesn’t know or care about any of this. It returns all the formats. The routing happens at the framework level. Adding a new channel means defining a URI scheme, writing a template, and teaching the channel adapter how to extract and send it. The tools never change.


Here’s what a Slack Work Object template looks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
event_type: weather_report

entity:
  url: "{{openweatherUrl}}"
  external_ref:
    id: "{{externalRefId}}"
    type: weather_forecast
  entity_type: "slack#/entities/item"
  entity_payload:
    attributes:
      title:
        text: "Weather Forecast for {{location}}"
      product_icon:
        url: "{{conditionImage}}"
        alt_text: "{{conditionDescription}}"  # Don't forget this.
    custom_fields:
      - key: temperature
        label: Temperature
        value: "{{temperature}}"

The MCP server registers template types using a DSL:

1
2
3
4
5
6
7
slack_work_object_resource "slack://work-objects/weather/{instance_id}",
  name: "Weather Work Object",
  description: "Displays weather as a Slack Work Object"

slack_blocks_resource "slack://blocks/weather/{instance_id}",
  name: "Weather Block Kit",
  description: "Displays weather as Slack Block Kit"

When the tool runs, it passes data to the template service, which hydrates all the registered templates and bundles them into the response. One tool call, multiple output formats, zero channel awareness in the tool itself.


A few things that will save you time.

Enable Work Objects in your Slack app first. Go to api.slack.com/apps, find your app, navigate to Work Object Previews, and enable slack#/entities/item. Without this, your metadata will be silently ignored regardless of whether it’s valid.

Always log the full API response from Slack. Their warning field contains information that doesn’t surface as an error.

1
2
3
if response["warning"].present?
  logger.warn "Slack warning: #{response['warning']} - #{response.dig('response_metadata', 'messages')}"
end

Use mrkdwn, not Markdown. Slack’s format uses single asterisks for bold. Don’t write your own converter. The slack-ruby-client gem handles it:

1
Slack::Messages::Formatting.markdown(text)

Test Block Kit in the builder, but Work Objects require deployment. You can validate Block Kit JSON at app.slack.com/block-kit-builder before templating it. Work Objects don’t have an equivalent preview tool. You have to send them to a real channel and check whether they render. This makes the logging advice above even more important.


The bigger takeaway is about building agent systems that interact with external APIs. Silent failures are everywhere. APIs optimized for human developers degrade gracefully in ways that make agent verification hard.

When your agent calls an external service and gets a success response, that’s not enough. You need to verify the actual outcome. Did the Work Object render? Did the email actually send? Did the database row actually update?

For agents, success is not “the API accepted the request.” Success is “the world changed.”