Salesforce has had “headless” capabilities since 2006. REST and SOAP APIs. Connected Apps. OAuth flows. Anyone who needed a custom UI on top of Salesforce has been able to build one for almost two decades. That is the pattern, and it has never required marketing.
Headless 360 is something else. It is the product Salesforce shipped at TrailblazerDX 2026 — an explicit, branded commitment that every capability of the platform is reachable through one of three programmable surfaces: REST/SOAP APIs, the Model Context Protocol (MCP), and the sf CLI. The slogan, repeated in every keynote slide, is short enough to fit on a coffee mug: “Our API is the UI.”
I wrote about why this matters strategically and put together a foundational React + OAuth + REST walkthrough before TDX dropped. This post is the version that uses what TDX actually shipped. We will build a customer-facing React portal that talks to an Agentforce agent through the new REST API, wire up the Salesforce MCP server in our IDE so the developer feedback loop becomes conversational, and look at where the Agent Experience Layer fits in.
If you have ever wished your Salesforce backend could be invoked by anything — a React app, a mobile client, an AI agent in someone else’s product — this is the architecture.
The TL;DR
If you want the shape of the post in five bullets:
- Headless 360 is three surfaces, not one — REST/SOAP APIs (browser-free OAuth), MCP server (60+ tools across
metadata,data,testing,lwc,code-analysis,devops,mobile,users), andsf agentCLI commands. - Agentforce REST API lets you invoke an AI agent from any client — React, mobile, partner integration — using
POST /einstein/ai-agent/v1/agents/{agentId}/sessionsand message endpoints. @salesforce/mcpplugs your org directly into Claude Code, Cursor, and VS Code so the developer loop (query, deploy, test) becomes a conversation with the IDE.- The Agent Experience Layer (AXL) renders one agent into multiple surfaces (Slack, Teams, web, voice) without rewriting the agent logic. Your React UI is one head; AXL gives you the others for free.
- The real win is that humans and agents share the same backend contract. Build the data model and security once. Wire many heads on top.
The rest of this post walks the implementation end to end.
What Headless 360 Actually Is
If you treat Headless 360 as “Salesforce REST APIs with a fresh coat of paint,” you will miss the point. Three things are genuinely new, and a fourth is a category that did not exist before.
The three access patterns
| Surface | Audience | Best for | Auth |
|---|---|---|---|
| REST/SOAP APIs | Apps and integrations | CRUD, queries, agent sessions, custom Apex REST | OAuth 2.0 (PKCE for browser, JWT for servers) |
| MCP server | AI coding agents, IDEs | Deploys, queries, test runs, code generation, code analysis | Local org auth via sf CLI |
sf agent CLI | CI/CD pipelines, developer workflows | Agent spec generation, deployment, evaluation suites | JWT (no browser) |
These are complementary. The REST API is for application traffic: an end user clicks a button, your app talks to the platform. The MCP server is for developer traffic: an AI assistant in your editor performs work on your behalf. The CLI is the spine that both rely on, and the surface CI pipelines drive.
The nine components in one paragraph
Headless 360 ships with nine named components, but you do not need to memorize all of them on day one. Data 360 is the trusted data layer. Agentforce is the agent-building product. Agentforce Vibes 2.0 is the AI-native dev environment with 110 free requests per month through May 2026. The Agent Experience Layer (AXL) is the multi-surface renderer. DevOps Center MCP brings the CI/CD pipeline into natural language. Agent Script is an open-source behavior definition tool. Session Tracing (beta) gives you OpenTelemetry-compatible observability for agent runs. AgentExchange is the marketplace. Agent Fabric is the central governance plane. For this guide, we will exercise Agentforce, AXL, DevOps Center MCP, and Session Tracing.
Agentforce vs. Headless 360
These get conflated constantly and they should not be:
Agentforce is the product for building AI agents. Headless 360 is the infrastructure that makes the entire Salesforce platform — including Agentforce — programmatically accessible to any compatible client.
You can use Agentforce without thinking about Headless 360 (just talk to it through the Lightning UI). You cannot use Headless 360 meaningfully without something to point it at, and Agentforce is usually that something in 2026.
Why this matters in 2026
AI agents need a programmable backend. Not a UI to scrape, not a screenshot to parse, not a screen-reader workaround. A real backend with a contract, sharing rules, validation, and audit trails. Headless 360 is Salesforce’s claim that they want to be that backend — not just for your React app, but for the autonomous agents that will increasingly be the primary consumers of enterprise data.
If that bet pays off, Salesforce stops being “the UI for sales reps” and starts being “the governance layer the entire enterprise AI stack runs through.” That is a much bigger market.
The Business Case: Atlas Care
Throughout this guide we will build for a fictional B2B SaaS company called Atlas Care. Their product is a workforce management platform sold to mid-market healthcare staffing agencies. They have three real needs that exercise all three Headless 360 surfaces:
-
Customer portal — Their customers (clinic administrators) want a clean React-based portal to view active contracts, usage, support tickets, and renewal status. Internal sales reps still use Lightning Experience; the portal is purely customer-facing.
-
AI-powered support assistant — Customers should be able to ask questions in natural language: “How many appointments did we book last month?” — “What is my renewal date and current plan tier?” — “Open a P2 ticket for our scheduling sync failing.” The assistant runs as an embedded Agentforce agent inside the portal, with structured outputs (decision tiles, action buttons) rendered natively in React.
-
AI-accelerated engineering team — Atlas Care’s small platform team uses Claude Code and Cursor. They want their IDE to talk to the Salesforce org directly — deploy metadata, run Apex tests, query records — without context-switching to the Setup menu or the CLI in a separate terminal.
Three customer types. Three Headless 360 surfaces. One backend.
This is the architecture you will actually see in the wild over the next two years. The customer portal is mostly REST API. The embedded assistant is Agentforce REST API. The engineering workflow is MCP. The same sharing rules, the same validation logic, the same Apex automations sit underneath all three.
The Architecture
┌────────────────────┐ ┌────────────────────┐
│ React Portal │ │ Developer IDE │
│ (customer-facing) │ │ (Claude Code, │
│ │ │ Cursor, VS Code) │
└──────────┬─────────┘ └──────────┬─────────┘
│ HTTPS + OAuth │ MCP stdio
▼ ▼
┌────────────────────────────────────────────┐
│ Salesforce Headless 360 │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ Agentforce │ │ @salesforce/mcp │ │
│ │ REST API │ │ (60+ tools) │ │
│ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌──────▼───────────────────▼─────────┐ │
│ │ Platform: Data + Logic + Security │ │
│ │ Sharing rules, FLS, Apex, Flow, │ │
│ │ Validation, Trust Layer, Audit │ │
│ └────────────────────────────────────┘ │
└────────────────────────────────────────────┘
Notice what is missing from this diagram: a separate trust layer, a separate identity broker, a separate observability stack for each consumer. Headless 360 inherits all of that from the platform. The Einstein Trust Layer applies whether the agent is invoked from a browser, an MCP tool call, or a partner API. Sharing rules apply regardless of who is asking. That is the architectural payoff.
Part 1 — Salesforce Setup
I will assume you have a Salesforce Developer Edition org with Agentforce enabled (it is free for developers through May 2026). If you do not, sign up at developer.salesforce.com.
1.1 Enable Agentforce and Data Cloud
Go to Setup → Agentforce → Setup and toggle Agentforce on for your org. This also provisions a Data Cloud sandbox (Headless 360 components are tightly coupled to Data 360 for grounding).
1.2 Create the data model
Create two custom objects for our scenario:
| Object | Key fields |
|---|---|
Account_Plan__c | Plan tier (picklist), Renewal date, MRR, Account lookup |
Support_Ticket__c | Subject, Priority, Status, Description, Account lookup |
Add a handful of sample records linked to a test Account. We will use these as both the source for the React portal and as knowledge the agent can ground against.
1.3 Sharing rules — the security boundary
Set the OWD for Account_Plan__c and Support_Ticket__c to Private. Then create sharing rules granting Read access to the Customer Community User profile based on Account = $User.Contact.Account.
This step is non-negotiable. Once we open up Agentforce REST API access, an agent will be able to query records on a user’s behalf. The platform enforces sharing on every query, regardless of whether it came from the Lightning UI, a REST call, or a tool invocation inside the agent. Skip the sharing rules, and you leak data the moment you ship.
1.4 Connected App with PKCE
The Connected App configuration is identical to the foundational guide — see the step-by-step walkthrough for the screen-by-screen. The only addition for Headless 360 is to add the einstein_gpt_api scope to the OAuth scopes list. Without it, agent session endpoints will return 403.
1.5 Permission sets
Create or assign two permission sets:
AtlasCare_Portal_User— Granted to Customer Community Users. Includes read access toAccount_Plan__candSupport_Ticket__c, and “Use Agentforce Agents.”AtlasCare_Agent_Caller— Granted to the integration user the React app authenticates as (if you use a per-app service account pattern). Includes “Invoke Agent Sessions via API.”
Part 2 — Build the Atlas Care Agent
We will build the agent from the CLI rather than the UI. This is the Headless 360 mindset: every step that used to require clicking through Lightning Setup now has a CLI verb. That matters because it means your agent definitions live in source control, ship through CI, and can be code-reviewed.
2.1 Generate the agent spec
sf agent generate spec \
--type customer-facing \
--role "Handle tier-1 support questions and account queries for Atlas Care customers." \
--company-name "Atlas Care" \
--company-description "Workforce management platform for healthcare staffing agencies." \
--output-dir specs
This produces a YAML file under specs/. Open it. You will see a structure like this:
name: AtlasCareSupportAgent
type: customer-facing
description: >
Tier-1 support and account intelligence for Atlas Care customers.
topics:
- name: AccountQueries
description: Questions about plan, renewal, MRR, and usage.
actions:
- GetMyPlanUsage
- GetRenewalDate
- name: SupportTickets
description: Create, view, or update tickets.
actions:
- ListMyOpenTickets
- CreateSupportTicket
knowledge:
- source: data-cloud
objects: [Account_Plan__c, Support_Ticket__c]
guardrails:
einstein-trust-layer: enabled
scope-restrictions:
- "Only answer Atlas Care related questions."
- "Refuse to expose data from other customer accounts."
The spec is the agent’s behavioral contract. Topics are reasoning categories. Actions are the things the agent can actually do.
2.2 Implement a custom action
Most actions can be auto-generated as standard data queries. But for GetMyPlanUsage, we want a small Apex method that does some computation. Create force-app/main/default/classes/GetMyPlanUsage.cls:
public with sharing class GetMyPlanUsage {
@InvocableMethod(
label='Get My Plan Usage'
description='Returns the current usage summary for the calling user\'s account.'
)
public static List<UsageResponse> getUsage(List<UsageRequest> requests) {
List<UsageResponse> responses = new List<UsageResponse>();
for (UsageRequest req : requests) {
// Sharing context ensures we only see the user's own account
List<Account_Plan__c> plans = [
SELECT Plan_Tier__c, MRR__c, Renewal_Date__c, Active_Seats__c
FROM Account_Plan__c
WHERE Account__c = :req.accountId
WITH SECURITY_ENFORCED
LIMIT 1
];
UsageResponse resp = new UsageResponse();
if (plans.isEmpty()) {
resp.summary = 'No active plan found.';
} else {
Account_Plan__c p = plans[0];
resp.summary = String.format(
'Plan: {0}. Active seats: {1}. MRR: ${2}. Renews on {3}.',
new List<String>{
p.Plan_Tier__c,
String.valueOf(p.Active_Seats__c),
String.valueOf(p.MRR__c),
String.valueOf(p.Renewal_Date__c)
}
);
}
responses.add(resp);
}
return responses;
}
public class UsageRequest {
@InvocableVariable(required=true) public String accountId;
}
public class UsageResponse {
@InvocableVariable public String summary;
}
}
The WITH SECURITY_ENFORCED clause is critical. The agent will invoke this method on behalf of whichever user authenticated to the Agentforce session — typically a Customer Community User. Sharing rules and FLS automatically scope the result.
2.3 Deploy and test
# Deploy
sf project deploy start --source-dir force-app
# Build a test suite from the spec
sf agent test create --from-spec specs/AtlasCareSupportAgent.yaml
# Run the test suite
sf agent test run --suite AtlasCareSupportAgent_tests --target-org AtlasCareDev
The test suite runs your agent against scripted prompts and asserts on the responses. Treat this as your regression net — every change to the agent spec or its actions should run the suite in CI before merging.
An agent without an evaluation suite is a feature flag without monitoring. You will not notice when it drifts until customers complain.
Part 3 — React Frontend Calling Agentforce REST API
Now we wire the agent into the React customer portal. The OAuth + PKCE setup is identical to what we walked through in the foundational guide. The new piece is the Agentforce REST API.
3.1 The Agentforce session API
An agent conversation has two steps: create a session, then send messages to it.
Create a session:
POST {instance_url}/services/data/v62.0/einstein/ai-agent/v1/agents/{agentId}/sessions
Authorization: Bearer {access_token}
Content-Type: application/json
{
"context": {
"accountId": "001xx000003GxxxAAA",
"userId": "005xx000001SyyyAAA",
"locale": "en_US"
}
}
The response includes a sessionId you will use for all subsequent message turns:
{
"sessionId": "0WfaxxxxxxxxxxxxxAAA",
"agentId": "0XxaxxxxxxxxxxxxxAAA",
"expiresAt": "2026-05-11T18:24:00Z"
}
Send a message:
POST {instance_url}/services/data/v62.0/einstein/ai-agent/v1/sessions/{sessionId}/messages
Authorization: Bearer {access_token}
Content-Type: application/json
{
"message": "What is my plan and when does it renew?",
"streaming": true
}
If streaming: true, the response is an SSE stream. If false, you get a single JSON document with the final answer. For an embedded chat UI, you almost always want streaming so the user sees tokens arrive as the agent thinks.
3.2 The React client
Add a small wrapper around the API to your existing project structure:
src/api/agentforce.ts:
import { getAccessToken, getInstanceUrl } from "../auth/authService";
const API_VERSION = import.meta.env.VITE_SF_API_VERSION;
const AGENT_ID = import.meta.env.VITE_AGENTFORCE_AGENT_ID;
export interface AgentSession {
sessionId: string;
agentId: string;
expiresAt: string;
}
export interface AgentMessage {
id: string;
role: "user" | "agent";
content: string;
// Structured outputs from the agent (e.g., decision tiles, action buttons)
attachments?: AgentAttachment[];
createdAt: string;
}
export interface AgentAttachment {
type: "tile" | "action" | "table";
payload: unknown;
}
export async function createSession(
context: Record<string, unknown>
): Promise<AgentSession> {
const res = await fetch(
`${getInstanceUrl()}/services/data/${API_VERSION}/einstein/ai-agent/v1/agents/${AGENT_ID}/sessions`,
{
method: "POST",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ context }),
}
);
if (!res.ok) throw new Error(`Session create failed: ${res.status}`);
return res.json();
}
export async function sendMessage(
sessionId: string,
message: string,
onChunk: (chunk: string) => void,
onAttachment: (a: AgentAttachment) => void
): Promise<void> {
const res = await fetch(
`${getInstanceUrl()}/services/data/${API_VERSION}/einstein/ai-agent/v1/sessions/${sessionId}/messages`,
{
method: "POST",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({ message, streaming: true }),
}
);
if (!res.ok || !res.body) {
throw new Error(`Message send failed: ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE events are separated by double newlines
const events = buffer.split("\n\n");
buffer = events.pop() || "";
for (const event of events) {
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
if (!dataLine) continue;
const json = JSON.parse(dataLine.slice(6));
if (json.type === "token") onChunk(json.content);
if (json.type === "attachment") onAttachment(json.attachment);
}
}
}
3.3 The chat component
src/components/AgentChat.tsx:
import { useEffect, useRef, useState } from "react";
import {
createSession,
sendMessage,
AgentMessage,
AgentAttachment,
} from "../api/agentforce";
interface Props {
accountId: string;
userId: string;
}
export default function AgentChat({ accountId, userId }: Props) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<AgentMessage[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const draftRef = useRef<string>("");
// Open a session on mount
useEffect(() => {
createSession({ accountId, userId, locale: "en_US" })
.then((s) => setSessionId(s.sessionId))
.catch((e) => console.error("Session create failed:", e));
}, [accountId, userId]);
async function handleSubmit() {
if (!sessionId || !input.trim() || streaming) return;
const userMsg: AgentMessage = {
id: crypto.randomUUID(),
role: "user",
content: input,
createdAt: new Date().toISOString(),
};
setMessages((m) => [...m, userMsg]);
setInput("");
const agentMsgId = crypto.randomUUID();
draftRef.current = "";
setMessages((m) => [
...m,
{
id: agentMsgId,
role: "agent",
content: "",
attachments: [],
createdAt: new Date().toISOString(),
},
]);
setStreaming(true);
try {
await sendMessage(
sessionId,
userMsg.content,
(chunk) => {
draftRef.current += chunk;
setMessages((m) =>
m.map((msg) =>
msg.id === agentMsgId ? { ...msg, content: draftRef.current } : msg
)
);
},
(attachment) => {
setMessages((m) =>
m.map((msg) =>
msg.id === agentMsgId
? {
...msg,
attachments: [...(msg.attachments || []), attachment],
}
: msg
)
);
}
);
} catch (e) {
console.error("Stream failed:", e);
} finally {
setStreaming(false);
}
}
return (
<div className="agent-chat">
<div className="agent-messages">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} />
))}
{streaming && <div className="typing">Atlas is thinking…</div>}
</div>
<div className="agent-input">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
placeholder="Ask about your account, plan, or open a ticket…"
disabled={!sessionId || streaming}
/>
<button onClick={handleSubmit} disabled={!sessionId || streaming}>
Send
</button>
</div>
</div>
);
}
function MessageBubble({ message }: { message: AgentMessage }) {
return (
<div className={`bubble bubble-${message.role}`}>
<div className="bubble-content">{message.content}</div>
{message.attachments?.map((a, i) => (
<Attachment key={i} attachment={a} />
))}
</div>
);
}
function Attachment({ attachment }: { attachment: AgentAttachment }) {
switch (attachment.type) {
case "tile":
return <DecisionTile payload={attachment.payload} />;
case "action":
return <ActionButton payload={attachment.payload} />;
case "table":
return <DataTable payload={attachment.payload} />;
default:
return null;
}
}
// Implementations of DecisionTile, ActionButton, DataTable
// would render the structured outputs natively in your design system.
function DecisionTile({ payload }: { payload: unknown }) {
const p = payload as { title: string; subtitle: string; cta: string };
return (
<div className="tile">
<h4>{p.title}</h4>
<p>{p.subtitle}</p>
<button>{p.cta}</button>
</div>
);
}
function ActionButton({ payload }: { payload: unknown }) {
const p = payload as { label: string; actionUrl: string };
return <a className="action-button" href={p.actionUrl}>{p.label}</a>;
}
function DataTable({ payload }: { payload: unknown }) {
const p = payload as { columns: string[]; rows: string[][] };
return (
<table>
<thead>
<tr>{p.columns.map((c) => <th key={c}>{c}</th>)}</tr>
</thead>
<tbody>
{p.rows.map((r, i) => (
<tr key={i}>{r.map((cell, j) => <td key={j}>{cell}</td>)}</tr>
))}
</tbody>
</table>
);
}
A few notes on what is happening here:
- Sessions are stateful. Once you create one, the agent retains conversation context until it expires (typically 30 minutes of inactivity). Do not recreate sessions per message.
- Streaming uses SSE. This is significantly better UX than waiting for the full response. The component shows partial tokens as they arrive.
- Structured outputs render natively. The agent can emit “attachments” — tiles, action buttons, tables — that are rendered by your design system instead of as text. This is what makes the embedded experience feel native rather than like a chat dropped into an app.
- No business logic in React. The agent decides what to surface. The React app just decides how to render it. Refresh-the-page-and-rebuild-the-state remains the design discipline.
3.4 Where to put session creation in production
In the example above, the browser creates the session directly. For production, move session creation to a backend-for-frontend (BFF). Reasons:
- The agent invocation involves the
einstein_gpt_apiscope, which is more sensitive than standard data access. Holding that token in the browser is a larger blast radius. - You can rate-limit per user at the BFF, preventing one user from exhausting your daily API quota.
- You can attach server-side audit context (request IDs, IP, user agent) before forwarding to Salesforce.
The browser still streams messages directly to the SSE endpoint — that traffic is fine. But the session-creation handshake belongs on the server.
Part 4 — MCP for the Developer Workflow
Now we leave the customer portal and shift to the developer side. This is where Headless 360 changes how it feels to build on Salesforce.
4.1 Install the Salesforce MCP server
npx -y @salesforce/mcp \
--orgs DEFAULT_TARGET_ORG \
--toolsets metadata,data,testing,lwc,code-analysis
This launches an MCP server that exposes 60+ tools to whatever MCP client is running. The --toolsets flag is critical: each toolset costs context window in the AI client. Pin the ones you actually need.
| Toolset | Tools | Use when |
|---|---|---|
metadata | Deploy, retrieve, compare metadata | You want the AI to ship changes to your org |
data | SOQL queries, record CRUD | You want the AI to read or write records |
testing | Run Apex tests, agent eval suites | You want the AI to verify before merging |
lwc | Create LWCs, generate Jest tests | You are doing UI work |
code-analysis | Static analysis via Code Analyzer | You want a second opinion before commit |
devops | DevOps Center pipeline operations | You manage CI/CD through the AI |
mobile | Mobile-specific capabilities | You build for Salesforce Mobile |
users | Permission set assignment | You manage access programmatically |
4.2 Wire it into your IDE
Claude Code — Create .mcp.json at the project root:
{
"mcpServers": {
"salesforce": {
"command": "npx",
"args": [
"-y",
"@salesforce/mcp",
"--orgs", "AtlasCareDev",
"--toolsets", "metadata,data,testing,lwc"
]
}
}
}
VS Code (with Copilot or Continue) — .vscode/mcp.json:
{
"servers": {
"Salesforce DX": {
"command": "npx",
"args": [
"-y",
"@salesforce/mcp",
"--orgs", "AtlasCareDev",
"--toolsets", "metadata,data,testing,lwc"
]
}
}
}
Cursor — Same pattern; the server lives in ~/.cursor/mcp.json for global use or in .cursor/mcp.json per project.
Before the server starts, the local sf CLI must have an authenticated session to the target org. Run sf org login web --alias AtlasCareDev once. After that, the MCP server picks up the credentials silently.
4.3 What this actually feels like
Here is a real day of Atlas Care platform work using the MCP server in Claude Code:
“Deploy the AtlasCareSupportAgent metadata to AtlasCareDev, then run the agent test suite. If anything fails, summarize what broke.”
The model invokes metadata.deploy, watches the deployment, then invokes agent.test.run. If failures come back, it reads the test output and explains.
“Find all
Account_Plan__crecords where the renewal date is in the next 60 days and MRR is over $5,000.”
The model writes the SOQL, invokes data.query, and shows the result inline. No switching to Workbench. No copying into the Developer Console.
“Generate an LWC for the renewal countdown widget. It should take an
accountPlanIdAPI property, query the renewal date, and show a styled countdown.”
The model invokes lwc.create, writes the JS and HTML, runs lwc.generateTests, and pushes the result back into the workspace. You review the diff and accept or reject.
This is not a marketing demo. The MCP loop genuinely changes the friction floor of working with the platform. The things that used to require seven tabs (Setup → Apex → Test → Logs → Inspector → Workbench → CLI) collapse into one conversational thread.
4.4 JWT auth for CI/CD
For pipeline use (no browser), authenticate the CLI with a JWT bearer flow:
sf org login jwt \
--client-id ${CONSUMER_KEY} \
--jwt-key-file server.key \
--username ci-deploy@atlascare.com.dev \
--alias AtlasCareDev
The Connected App needs the JWT flow enabled (separate from the PKCE web flow used by the portal) and the certificate uploaded. Once that is in place, your CI runner can drive sf project deploy, sf apex run test, and sf agent test run with zero human interaction.
4.5 MCP security — read this twice
The same toolsets that make your day frictionless make the platform extremely exposed if you point them at the wrong thing:
- Never run
--toolsets metadataagainst production from a developer’s laptop. Production deploys go through CI, not through a chat session. - Never enable
--allow-non-ga-toolsin any context that matters. Those tools are experimental for a reason. - Pin toolsets per project. A repo for a customer portal almost certainly does not need
userstoolset access. Less surface, less risk. - Audit the MCP server’s outbound calls. The Session Tracing component (beta) ships an OpenTelemetry pipeline you can forward to Datadog or Honeycomb.
Part 5 — AXL: One Agent, Many Surfaces
We built a React UI for the Atlas Care assistant. That same agent can render in Slack, Teams, and over voice without rewriting anything — provided we use the Agent Experience Layer (AXL) rather than our custom React component for those channels.
AXL works by intercepting the agent’s structured outputs and rendering them in surface-native primitives. A “decision tile” becomes a Slack Block Kit card. An “action button” becomes a Teams adaptive card button. A “data table” becomes a formatted text response over voice.
| Surface | Built with | Trade-off |
|---|---|---|
| Custom React in portal | Agentforce REST API + your design system | Total control, more code |
| Slack | AXL + Slack connector | Zero code, less brand control |
| Microsoft Teams | AXL + Teams connector | Zero code, less brand control |
| Voice (phone or device) | AXL + Voice connector | Speech rendering automatic |
| AXL + WhatsApp Business API | Compliance-aware |
The decision tree is straightforward: the surface that is core to your product gets a custom render. Every other surface uses AXL. For Atlas Care, the customer portal is the product, so the React renderer is custom. Slack and Teams are nice-to-have channels where customers can DM the agent for quick lookups, so AXL is exactly right there.
The point of AXL is not that you stop writing UIs. It is that you stop writing the eleventh version of the same UI. Pick the one or two surfaces where pixel control matters and let AXL handle the long tail.
Production Considerations
A working dev demo is not a production system. A few things to think hard about before you ship.
Einstein Trust Layer
Every agent invocation — whether it came from your React portal, Slack via AXL, or an MCP tool call from someone’s IDE — passes through the Trust Layer. Data masking, dynamic grounding, zero-data-retention with LLM providers, and FLS enforcement are uniform across surfaces. This is genuinely a strong story; few competing platforms can claim equivalent governance on agent traffic.
What you still own:
- Defining which fields are sensitive (mask them)
- Defining which data sources the agent can ground against (allowlist them)
- Reviewing the Trust Layer audit logs periodically
API quotas
Every Agentforce message counts against your daily API limit. A chat with 20 turns is 20 API calls plus any tool invocations the agent makes (each one is another call). At scale this adds up fast:
- Enterprise Edition starts at 100,000 REST API calls per 24-hour rolling window
- A modestly engaged portal with 500 daily users averaging 4 agent turns each = 2,000 calls
- If each turn triggers 3 tool calls = 8,000 calls
- Plus everything else your portal does = trouble by lunch
Mitigations:
- Cache aggressively for reference data
- Use the Composite API to bundle reads
- Move high-volume queries to Data Cloud (separate quota pool)
- Monitor
Sforce-Limit-Infoheaders and alert at 70%
Session Tracing
Headless 360 ships an OpenTelemetry-compatible tracing pipeline (currently in beta). Wire it up to Datadog, Honeycomb, or your APM of choice. The traces include token counts, tool invocations, latency per turn, and Trust Layer decisions. Without observability you cannot debug agent regressions, and you absolutely will have agent regressions.
Agent Fabric for governance
In multi-org setups, Agent Fabric is the central control plane. It governs which orgs can invoke which agents, sets per-agent rate limits, and maintains the global registry of agent versions. If you are running Headless 360 across more than one org, configure Agent Fabric on day one.
MCP server in CI
If you put the Salesforce MCP server in CI (some teams do — they let the AI write deploy plans before merge), pin the toolsets aggressively and put it behind a service account with the minimum permissions to do its job. A misconfigured MCP server with --toolsets metadata and admin credentials is a path to incident.
Common Pitfalls
A few specific things to avoid, learned from people who got bit:
-
Do not expose Agentforce session creation directly from the browser in production. Use a BFF. The
einstein_gpt_apiscope is more sensitive than your standard REST traffic and deserves server-side handling. -
Do not run MCP with
--allow-non-ga-toolsin production pipelines. It is fine for solo experimentation. It is not fine when an AI is allowed to take actions you have not vetted. -
Agent responses are non-deterministic. Build retry paths, fallbacks, and graceful “I could not answer that” handling. Do not assume the agent will produce the same output twice.
-
Do not conflate “headless” with “Headless 360.” The pattern existed before; the product is new. Headless 360 is specifically the MCP + Agentforce REST API + CLI bundle.
-
Do not skip
sf agent test run. An agent without an evaluation suite drifts silently. The suite is your only signal that today’s agent behaves like yesterday’s. -
Do not forget that MCP tool calls cost API limits. Every “find me records where…” an AI assistant runs is a real SOQL query that counts against your quota. Budget for it.
-
Do not use the MCP server’s
metadatatoolset against production from a developer machine. Production deploys go through CI with audited service accounts. Treat the MCP toolset likesudo— local-only for dev orgs.
Open Questions
A few things I am still chewing on, and that I would love to hear other architects’ takes on:
-
What is the right pricing model for agent invocations? Per-user licensing breaks down when the “user” is an autonomous agent. Salesforce has not yet disclosed Headless 360 pricing at scale. The economics will determine whether the architecture genuinely democratizes or remains a premium feature.
-
How do you version an agent’s behavior across releases? Agents are partly prompt, partly action code, partly model. A “version” is not a single artifact. Salesforce’s answer (AgentExchange + Agent Fabric) helps, but real teams will need stricter discipline than the platform currently enforces.
-
When do you write Apex versus let the agent reason? A hard-coded Apex method is deterministic and cheap. An agent reasoning over the same data is flexible but expensive and slower. The answer is probably “Apex for things you can specify, agents for things you cannot” — but that is a much harder line to draw in practice than in theory.
-
Where do humans stay in the loop? Headless 360 makes it easy to put agents in autonomous mode. The interesting design work is figuring out which decisions still need a human and how to surface the right context for that human to decide. AXL helps here, but the product design problem is unsolved.
Further Reading
If this post resonated, the rest of my Salesforce series goes deeper on the foundations:
- Salesforce Just Broke Up With Its Own UI — Strategic context for why Salesforce shipped Headless 360 in the first place.
- Building Headless Salesforce With React — The foundational guide for the React + OAuth + REST patterns this post builds on top of.
- Agentforce for Developers — Deep dive on agent fundamentals, topics, actions, and the Apex integration points.
- Data Cloud and Agentforce in Salesforce — Data 360 layer, grounding sources, and how the agent’s knowledge plane is constructed.
- Authentication Flows, Integration Patterns, and ESBs — OAuth flows, JWT, and the integration plumbing Headless 360 sits on top of.
- Salesforce Security for Architects — Sharing rules, FLS, and the governance layer that protects Headless 360 traffic.
The code patterns in this guide are starting points, not production systems. Take them, adapt them, and ship something that exercises all three Headless 360 surfaces. That is the fastest way to internalize what is genuinely new about this announcement versus what is “headless Salesforce as it has always been.”