Tech · · 28 min read

Salesforce Headless 360 in Practice: React UI, Agentforce API, and MCP Developer Workflow

Salesforce Headless 360 is not just headless Salesforce. It is three programmable surfaces — REST, MCP, and CLI — that turn the platform into infrastructure for AI agents and modern frontends. Here is the working playbook with React, Agentforce, and the Salesforce MCP server.

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), and sf agent CLI 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}/sessions and message endpoints.
  • @salesforce/mcp plugs 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

SurfaceAudienceBest forAuth
REST/SOAP APIsApps and integrationsCRUD, queries, agent sessions, custom Apex RESTOAuth 2.0 (PKCE for browser, JWT for servers)
MCP serverAI coding agents, IDEsDeploys, queries, test runs, code generation, code analysisLocal org auth via sf CLI
sf agent CLICI/CD pipelines, developer workflowsAgent spec generation, deployment, evaluation suitesJWT (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:

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

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

  3. 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:

ObjectKey fields
Account_Plan__cPlan tier (picklist), Renewal date, MRR, Account lookup
Support_Ticket__cSubject, 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 to Account_Plan__c and Support_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:

  1. The agent invocation involves the einstein_gpt_api scope, which is more sensitive than standard data access. Holding that token in the browser is a larger blast radius.
  2. You can rate-limit per user at the BFF, preventing one user from exhausting your daily API quota.
  3. 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.

ToolsetToolsUse when
metadataDeploy, retrieve, compare metadataYou want the AI to ship changes to your org
dataSOQL queries, record CRUDYou want the AI to read or write records
testingRun Apex tests, agent eval suitesYou want the AI to verify before merging
lwcCreate LWCs, generate Jest testsYou are doing UI work
code-analysisStatic analysis via Code AnalyzerYou want a second opinion before commit
devopsDevOps Center pipeline operationsYou manage CI/CD through the AI
mobileMobile-specific capabilitiesYou build for Salesforce Mobile
usersPermission set assignmentYou 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__c records 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 accountPlanId API 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 metadata against production from a developer’s laptop. Production deploys go through CI, not through a chat session.
  • Never enable --allow-non-ga-tools in 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 users toolset 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.

SurfaceBuilt withTrade-off
Custom React in portalAgentforce REST API + your design systemTotal control, more code
SlackAXL + Slack connectorZero code, less brand control
Microsoft TeamsAXL + Teams connectorZero code, less brand control
Voice (phone or device)AXL + Voice connectorSpeech rendering automatic
WhatsAppAXL + WhatsApp Business APICompliance-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-Info headers 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:

  1. Do not expose Agentforce session creation directly from the browser in production. Use a BFF. The einstein_gpt_api scope is more sensitive than your standard REST traffic and deserves server-side handling.

  2. Do not run MCP with --allow-non-ga-tools in production pipelines. It is fine for solo experimentation. It is not fine when an AI is allowed to take actions you have not vetted.

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

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

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

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

  7. Do not use the MCP server’s metadata toolset against production from a developer machine. Production deploys go through CI with audited service accounts. Treat the MCP toolset like sudo — 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:

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

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

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

  4. 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:

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