Build Once, Deploy Everywhere with HXL - Headless Experience Layer in Salesforce


Build the card once as JSON. Give each AI app its own Lightning type. The widget never changes.

Posted on SalesforceBolt.com  |  Headless Experience Layer  |  Agentforce  |  MCP

▶ Watch on YouTube
Build Once, Deploy Everywhere with HXL - Headless Experience Layer in Salesforce

Your agent finds the right answer, and the user still gets a paragraph of text. The Headless Experience Layer (HXL) lets you answer with a card instead: photos, prices, a Book button. And you build that card once. I built a car rental card for a fictional company, Roadster Rentals, and the same widget file renders in Agentforce inside Salesforce and in Claude through a Salesforce hosted MCP server. Here is how the pieces fit, in plain words.

The idea in three words

WordWhat it means here
WidgetThe design of the card. A JSON file made of tiles: text, image, badge, button. It has no idea which app shows it.
Lightning typeThe adapter. It takes the data an app receives and plugs each value into the right place in the widget.
MCPModel Context Protocol. How outside AI apps like Claude call your Salesforce logic.

Here is the key point. Agentforce and Claude receive the same data, packed differently. Agentforce gets the Apex output directly, like someone handing you a letter. Claude, through MCP, gets the whole tool result: an envelope with fields like actionName and isSuccess, and your data inside outputValues. So each path gets its own adapter: one for Agentforce, and one for MCP that any MCP client can use. The widget stays exactly the same.

What I built

You type I want to book a car at Hyderabad Airport. You get a card: the Tesla Roadster on top as the featured car at $1,999 a day, then the Mahindra XUV700, Toyota RAV4 and Tesla Model 3, cheapest first. Each car has a photo, specs, a rating and a Book button. A Maruti Suzuki Swift at the same airport is marked unavailable, so it never shows up. Agentforce and Claude show the same card.

Step 1: Start in the HXL Playground

Before you touch an org, open the HXL Playground at headlessexperiencelayer.com. It runs in your browser with no org and no login. It has a components reference with every tile and its properties, a widget editor with templates and a live preview that you can switch between surfaces such as Agentforce, Slackbot and ChatGPT, and step by step tutorials.

A widget has no data of its own, so the editor has a data area too. I pasted my widget into the structure and a sample of my Apex output under $attrs into the data. Clicking Book in the preview showed Sent: Book the Tesla Roadster at Hyderabad Airport, so the button worked before the widget ever reached an org. When you are happy, copy the JSON into the widget file in your project.

No car photos in the PlaygroundMy images are hosted on raw.githubusercontent.com, and the Playground's own Content Security Policy blocks that host. In Agentforce and Claude the photos load fine.

Step 2: Turn on HXL in your org

HXL is Beta, so it is off by default. In Setup, type headless in Quick Find, open Headless Experience Layer Settings under Feature Settings, review the beta terms and turn the feature on. Widgets need API version 67.0 or later, so set your project to 67.0.

Update your Salesforce CLI firstCLI 2.136.8 could not deploy the widget at all: roadsterFleet.uiwidget-meta.xml: Could not infer a metadata type. sf update stable to 2.154.4 fixed it.

Step 3: The Apex action prepares everything

One invocable Apex action finds the available cars. Its output is the data the card shows: a headline, the featured car, and the other cars.

RoadsterRentalsGetAvailableCars.cls (annotations hidden)
global class FleetResult {
    global String headline;
    global String locationLabel;
    global Integer totalCount;
    global Boolean hasFeatured;
    global Boolean isEmpty;
    global Car featuredCar;
    global List<Car> featuredCars;
    global List<Car> cars;
}

The habit worth copying: Apex prepares every piece of text the card shows, and every true or false the card checks. The widget only displays.

RoadsterRentalsGetAvailableCars.cls
car.ratingLabel = vehicle.Rating__c == null ? '' : 'Rated ' + vehicle.Rating__c.setScale(1) + ' / 5';

result.isEmpty = result.totalCount == 0;

Step 4: The widget is the card design

The widget lives in uiWidgets/roadsterFleet. Tiles show data through bindings in curly braces. $attrs means the data this widget was given.

roadsterFleet.json
{
  "definition": "tile/text",
  "attributes": {
    "text": "{!$attrs.headline}",
    "variant": "h1"
  }
}

To show many cars, a container loops over the list and names the current item $car. Inside, tiles read {!$car.name}, {!$car.imageUrl} and so on. The featured car on top uses the same loop over featuredCars, a list that holds only the featured car.

roadsterFleet.json
"meta": {
  "forEach": "{!$attrs.cars}",
  "forItem": "$car"
}

The Book button sends a message back into the chat, for example Book the Toyota RAV4 at Hyderabad Airport.

roadsterFleet.json
"actions": {
  "click": [
    {
      "definition": "action/sendMessage",
      "attributes": {
        "content": "{!$car.bookMessage}"
      }
    }
  ]
}

Notice that this file never mentions Agentforce or Claude. That is why it can be reused.

Step 5: Three Lightning types, one widget

This is the part that makes it click. The project has three Lightning types: one for Agentforce and two for MCP. It is the same structure as the hotel card examples in the HXL guide.

Lightning typeAppWhat it does
roadsterFleetResponseAgentforcePoints at the Apex class. Its renderer plugs the fields into the widget.
roadsterFleetResultV3Claude (MCP)Describes the whole tool result: actionName, isSuccess, outputValues. Its renderer plugs the fields into the widget.
roadsterFleetOutputValuesClaude (MCP)Describes what is inside outputValues. Schema only, no renderer.

Put the two renderers side by side and the whole trick is visible. Same widget. Only the path to the data changes.

roadsterFleetResponse/renderer.json (Agentforce, 2 of 8 attributes)
"definition": "@widget/c/roadsterFleet",
"attributes": {
  "headline": "{!$attrs.headline}",
  "cars": "{!$attrs.cars}"
}
roadsterFleetResultV3/renderer.json (MCP, 2 of 8 attributes)
"definition": "@widget/c/roadsterFleet",
"attributes": {
  "headline": "{!$attrs.outputValues.fleetResponse.headline}",
  "cars": "{!$attrs.outputValues.fleetResponse.cars}"
}
Setup shows only one of themSetup, Lightning Types listed only roadsterFleetResponse in my org. The MCP types were there all the same: sf org list metadata -m LightningTypeBundle listed them. Check MCP types from VS Code or the CLI.

Step 6: Point Agentforce at its Lightning type

The agent is an Agent Script employee agent. One line on the action output, complex_data_type_name, turns a text answer into the card. Before the change, Agentforce showed the output as a plain list of field values.

Roadster_Rentals_Assistant.agent
fleetResponse: object
    label: "Fleet Response"
    complex_data_type_name: "c__roadsterFleetResponse"
    is_displayable: True
    filter_from_agent: False

Step 7: Point the MCP server at its Lightning type, then connect Claude

Salesforce hosts the MCP server. You describe it in one McpServerDefinition file. The tool runs the same Apex class through its API Catalog entry, and the resource names the MCP Lightning type that draws the result.

roadsterRentalsMcp.mcpServerDefinition-meta.xml (excerpt)
<resources>
    <resourceName>roadsterFleetV3</resourceName>
    <resourceTitle>Roadster Rentals Fleet</resourceTitle>
    <resourceUri>ui://widget/lightningType/c__roadsterFleetResultV3</resourceUri>
</resources>
<tools>
    <apiDefinition>
        <apiIdentifier>aa:apex-RoadsterRentalsGetAvailableCars</apiIdentifier>
        <apiSource>API_CATALOG</apiSource>
        <operation>RoadsterRentalsGetAvailableCars</operation>
    </apiDefinition>
    <readOnly>true</readOnly>
    <toolName>getAvailableRentalCars</toolName>
    <uiResource>roadsterFleetV3</uiResource>
</tools>

Two things to match. The end of resourceUri is the API name of the MCP wrapper type from Step 5. And the tool's uiResource must equal resourceName exactly.

Then three short steps:

StepWhat I did
External Client AppDeployed as four metadata files: OAuth scopes MCP and RefreshToken, Claude's callback URL, PKCE required, secret optional.
Turn the server onSetup, MCP Servers, toggle the server on. Besides turning on HXL, this and copying the consumer key were my only steps in Setup.
Connect ClaudeAdd a custom connector with the server URL, choose your own OAuth client, paste the consumer key, leave the secret blank.
Keep the consumer key out of gitRetrieving ExtlClntAppGlobalOauthSettings writes the consumer key into the file. I added **/extlClntAppGlobalOauthSets/ to .gitignore.

What tripped me up on the MCP path

Agentforce was forgiving. The MCP path was stricter. These are the issues I hit, in the order they happened.

Apex types inside the widget schema fail to deployReferencing @apexClassType for the car objects in the widget's schema.json failed with Required fields are missing: [SourceType]. Inline lightning__objectType properties fixed it. Keep @apexClassType in the Lightning types, not in the widget schema.
A formula that worked in Agentforce failed through MCPMy empty state used {!$attrs.totalCount = 0}. It worked in Agentforce and in the Playground. Through MCP the tool call failed with Callout failed: Failed to evaluate $attrs expression: {!$attrs.totalCount = 0}. I moved the check into Apex as the Boolean isEmpty, and the rating text into Apex as ratingLabel. The widget now has no formulas. I could not prove why the comparison failed.
Nested object bindings were empty in ClaudeThe featured card first read {!$attrs.featuredCar.imageUrl} and similar paths. That rendered in Agentforce, but in Claude the card was blank while the list cars rendered fine. Rendering the featured car through a one-item list, featuredCars with forEach, works in both.
Claude kept showing an old widgetAfter a clean deploy, Claude still returned the old formula error. The org had no trace of it. Running Update tools on the connector and turning the server off and on did not help. Removing the connector in Claude and adding it again came right before it worked, about five minutes later. That happened once, so I can't prove it was the cause. A Salesforce Developers blog describes the same symptom and suggests versioning the resource URI, which is why my live type ends in V3. My advice: after any widget change, start a new chat, and if it is still stale, remove and re-add the connector.

What I tested, and what I did not

I built this in a Developer Edition org on Winter '27 (API 68.0), with the project at API 67.0 and Salesforce CLI 2.154.4. The card rendered in the Agentforce panel and in Claude. I did not test other MCP clients such as ChatGPT or Slackbot, and I did not test the Book button inside Claude. HXL is Beta, so check the current status before you plan production work around it.

Source code on GitHub

Github - Roadster Rentals HXL Widget
Key takeaways
  • The widget is the design. Build it once. It never mentions the app that shows it
  • A Lightning type is the adapter between how the data arrives and the widget
  • Agentforce uses one Lightning type. MCP uses two: the tool result and the data inside it
  • The only difference between the renderers is the binding path, $attrs.cars vs $attrs.outputValues.fleetResponse.cars
  • Keep the widget simple: no formulas, no nested object paths. Let Apex prepare labels, Booleans and lists
  • Try the widget in the HXL Playground with sample data before you deploy it
Headless Experience Layer HXL Agentforce MCP Lightning Types Salesforce Developers

Watch Complete Video on YouTube

 If you have any question please leave a comment below.

If you would like to add something to this post please leave a comment below.
Share this blog with your friends if you find it helpful!

Post a Comment

0 Comments