Build the card once as JSON. Give each AI app its own Lightning type. The widget never changes.
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
| Word | What it means here |
|---|---|
| Widget | The design of the card. A JSON file made of tiles: text, image, badge, button. It has no idea which app shows it. |
| Lightning type | The adapter. It takes the data an app receives and plugs each value into the right place in the widget. |
| MCP | Model 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.
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.
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.
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.
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.
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.
The Book button sends a message back into the chat, for example Book the Toyota RAV4 at Hyderabad Airport.
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 type | App | What it does |
|---|---|---|
roadsterFleetResponse | Agentforce | Points at the Apex class. Its renderer plugs the fields into the widget. |
roadsterFleetResultV3 | Claude (MCP) | Describes the whole tool result: actionName, isSuccess, outputValues. Its renderer plugs the fields into the widget. |
roadsterFleetOutputValues | Claude (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 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.
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.
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:
| Step | What I did |
|---|---|
| External Client App | Deployed as four metadata files: OAuth scopes MCP and RefreshToken, Claude's callback URL, PKCE required, secret optional. |
| Turn the server on | Setup, MCP Servers, toggle the server on. Besides turning on HXL, this and copying the consumer key were my only steps in Setup. |
| Connect Claude | Add a custom connector with the server URL, choose your own OAuth client, paste the consumer key, leave the secret blank. |
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.
@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.{!$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.{!$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.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
- 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.carsvs$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


0 Comments