Day 15/15 — Build a Complete Headless Salesforce AI Assistant
Fifteen days come together in one app: Salesforce sign-in with PKCE, a chat where Claude calls two hosted MCP servers as the signed-in user, a trace of every tool call, and an Approve button that is the only way to change data.

Neha has used Claude with Salesforce for two weeks: she reads her pipeline in plain language, confirms tasks before they are created, and gets a meeting brief before every call. Her admin, Arjun, has a different list. He wants one app his team can open, one External Client App he can switch off, one place that shows every tool call, and a guarantee that nothing changes in the org unless a person clicks a button.
That app is today's project. It is the companion repository you have been reading pieces of since Day 10, now assembled end to end: a headless Salesforce assistant in Next.js, with Salesforce Headless 360 (now called AIforce) behind it and Claude in the middle.
By the end of today you will have the complete assistant running against your Developer Edition org: Salesforce sign-in with PKCE, a chat where Claude calls two hosted Model Context Protocol (MCP) servers as Neha, a trace of every tool call, and an Approve button that is the only way to change data.
Everything here builds on earlier days; if you skipped any, the links below take you back to the exact step.
What we are building
Here is the demo, in the order a user sees it:
- Sign in. The first page shows the model and both server URLs, and one button: Sign in with Salesforce. Neha logs in and allows access.
- Ask a question. "How healthy is the Acme Global Tech account?" The trace shows
custom · getAccountHealthwith its input and output: a score, a status and a one-line summary from Apex. - Ask for the pipeline. "Show my open opportunities closing this quarter, biggest first." The trace shows
sobject-all · soqlQuerywith the query Claude wrote. - Ask for a change. "Create a follow-up task on Acme Global Tech to send the renewal quote next Friday." No task is created. An Approval needed card shows the tool, the exact arguments and a one-sentence summary.
- Approve. The trace says the write tool was enabled for that request only, shows
custom · createFollowUpTask, and ends with "Executed exactly as approved". - Check Salesforce. The Task is on Acme Global Tech, and Created By is Neha.
- Prepare for a meeting. Meeting brief in the top bar runs the read-only workflow from Day 10.
- Sign out. The app revokes Neha's token at Salesforce.
Every step was run in a Developer Edition org on 2026-10-04, and the results are quoted below where they matter. Assistant replies and the brief render as formatted Markdown: headings, lists, tables and links, built as React elements rather than injected HTML.




The repository has two halves. web/ is the Next.js app, with Vitest tests that mock every Salesforce and Anthropic call. salesforce/ is an SFDX project with two Apex tools, their tests, a Flow, a permission set and the custom server definition.
Set it up
Most of the setup happened earlier:
| Piece | What to do | Day |
|---|---|---|
| Org and CLI | A Developer Edition org, and sf org login web --alias headless360 --set-default |
Day 4 |
sobject-all |
Setup → Quick Find → "MCP Servers" → MCP Servers (under API Catalog) → Salesforce Servers → open sobject-all → Activate, then copy its Server URL |
Day 4 |
| Apex tools and custom server | Deploy, test and activate HeadlessAssistantTools (next section) |
Day 12 |
| External Client App | Headless Assistant Local, callback http://localhost:3000/api/auth/salesforce/callback, scopes Access Salesforce hosted MCP servers (mcp_api) and Perform requests at any time (refresh_token, offline_access), PKCE and JWT on, both "Require secret" boxes off |
Day 5 |
| Who may connect | On the app's Policies tab, Permitted Users set to Admin approved users are pre-authorized, with the Headless_Assistant_User permission set under Select Permission Sets |
Day 11 |
| A client to compare with | Claude connected to the same servers, so you can tell an app problem from an org problem | Day 6 |
You also need Node.js 20.19 or later and an Anthropic API key with credit. Then configure the app with the variables from web/.env.example:
cd salesforce-headless-360/web
cp .env.example .env.local
npm install
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" # paste into SESSION_SECRET
# web/.env.local (never commit it)
SF_CLIENT_ID=<HEADLESS_ASSISTANT_LOCAL_CONSUMER_KEY>
SF_CALLBACK_URL=http://localhost:3000/api/auth/salesforce/callback
SF_MCP_SOBJECT_URL=<SOBJECT_ALL_SERVER_URL>
SF_MCP_CUSTOM_URL=<CUSTOM_SERVER_URL>
ANTHROPIC_API_KEY=<YOUR_ANTHROPIC_API_KEY>
SESSION_SECRET=<GENERATED_VALUE>
Start it with npm run dev and open http://localhost:3000. SF_CALLBACK_URL must match the External Client App's callback exactly: scheme, host, port and path. Leave the four tool lists at their defaults: SF_MCP_SOBJECT_READ_TOOLS and SF_MCP_CUSTOM_READ_TOOLS name the tools enabled on every turn, and SF_MCP_SOBJECT_WRITE_TOOLS and SF_MCP_CUSTOM_WRITE_TOOLS name the tools Claude may propose. Any other tool stays off.
Deploy the Salesforce side and run the Apex tests
The Apex tools are Day 12's subject. Today you deploy them, run their tests and publish them as a custom hosted MCP server, with the Salesforce CLI or with Claude Code.
With the Salesforce CLI
These are the repository's commands, run from salesforce/:
# 1. Apex tools, Flow and permission set, running only the two tool test classes
sf project deploy start \
--source-dir force-app/main/default/classes \
--source-dir force-app/main/default/flows \
--source-dir force-app/main/default/permissionsets \
--test-level RunSpecifiedTests \
--tests AccountHealthToolTest --tests CreateFollowUpTaskToolTest \
--target-org headless360
# 2. The custom hosted MCP server definition
sf project deploy start --source-dir force-app/main/default/mcpServerDefinitions --target-org headless360
# 3. The permission set for your user, then the demo data
sf org assign permset --name Headless_Assistant_User --target-org headless360
sf apex run --file scripts/apex/seed-demo-data.apex --target-org headless360
# 4. The tests again, with coverage
sf apex run test --class-names AccountHealthToolTest --class-names CreateFollowUpTaskToolTest \
--code-coverage --result-format human --wait 10 --target-org headless360
RunSpecifiedTests runs exactly the two tool test classes, 13 test methods in all, including a respectsTheCallersAccess test per tool that runs as a Minimum Access user.
The second deploy uses the McpServerDefinition metadata type, which Salesforce says "can be source controlled and CI/CD deployed alongside the Apex class." Here is the first tool in the definition (HeadlessAssistantTools.mcpServerDefinition-meta.xml, lines 5–14):
<tools>
<apiDefinition>
<apiIdentifier>aa:apex-AccountHealthTool</apiIdentifier>
<apiSource>API_CATALOG</apiSource>
<operation>AccountHealthTool</operation>
</apiDefinition>
<descriptionOverride>Returns a health summary for one Account: open opportunities, open and weighted pipeline, deals closing in 30 days, open and high-priority cases, days since last activity, a 0-100 score, a Healthy/Watch/At Risk status and a one-line summary. Pass accountId, or accountName (partial names match).</descriptionOverride>
<toolName>getAccountHealth</toolName>
<toolTitle>Get Account Health</toolTitle>
</tools>
The aa:apex-<ClassName> identifier follows the sample repository behind Salesforce's May 2026 Apex blog post, and it deployed in the org test. The definition's first name, Headless_Assistant_Tools, failed metadata validation on deploy, so it is now HeadlessAssistantTools. In the same test all Apex tests passed, with 98% coverage for AccountHealthTool and 96% for CreateFollowUpTaskTool. Avoid building the server in Setup instead: tools added there get generated names such as CreateFollowUpTaskToolapex_CreateFollowUpTaskTool (Day 12), which the app's tool lists won't match. Activate the server and copy its URL: https://api.salesforce.com/platform/mcp/v1/custom/HeadlessAssistantTools.
Old CLIs do not know the type
An older Salesforce CLI rejects the .mcpServerDefinition-meta.xml suffix. Run sf update before the second deploy.
With Claude Code
To let a coding agent do it, give Claude Code the Salesforce DX MCP Server. This is the configuration from the server's README, saved as .mcp.json in salesforce/:
{
"mcpServers": {
"Salesforce DX": {
"command": "npx",
"args": ["-y", "@salesforce/mcp",
"--orgs", "DEFAULT_TARGET_ORG",
"--toolsets", "orgs,metadata,data,users",
"--tools", "run_apex_test",
"--allow-non-ga-tools"]
}
}
}
DEFAULT_TARGET_ORG limits the server to your CLI's default org. Then ask: Deploy the classes, flows and permission set in force-app to my default org, run AccountHealthToolTest and CreateFollowUpTaskToolTest, and show me the coverage. Read each tool call before you trust the result.
From sign-in to a Claude request

Sign-in with PKCE
The login route creates a PKCE verifier and challenge and an unguessable state, seals them into a short-lived cookie, and redirects to Salesforce (web/app/api/auth/salesforce/login/route.ts, lines 25–35):
const state = generateState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = codeChallengeS256(codeVerifier);
const response = redirectTo(request, authorizeUrl(config, { state, codeChallenge }));
response.cookies.set(
OAUTH_TX_COOKIE,
sealOAuthTransaction({ state, codeVerifier, createdAt: Date.now() }, config.sessionSecret),
cookieOptions(config.secureCookies, OAUTH_TX_MAX_AGE_SECONDS, OAUTH_TX_PATH),
);
return response;
The challenge uses S256, the only method Salesforce's discovery document lists. The transaction cookie lasts 10 minutes. On the way back, the callback compares state in constant time, exchanges the code with the verifier, and drops the transaction cookie whatever happens, so a verifier is never used twice. The helpers are the ones you wrote on Day 5.
The session
Salesforce's tokens never reach the browser. They live in a cookie sealed with AES-256-GCM, under a key derived from SESSION_SECRET with HKDF, marked httpOnly and SameSite=Lax, and split into chunks because JWT access tokens are large. The sealed session carries its own expiry: eight hours after it was last written, at sign-in or at a token refresh. Before any request that sends the token to Anthropic, the server checks its age (web/lib/salesforce-oauth.ts, lines 168–172):
export function needsRefresh(session: SessionData, config: AppConfig, now = Date.now()): boolean {
const { issuedAt, expiresAt } = session.tokens;
if (expiresAt !== undefined && expiresAt - EXPIRY_SKEW_MS <= now) return true;
return now - issuedAt >= config.salesforce.tokenMaxAgeSeconds * 1000;
}
The token is refreshed when it is older than SF_TOKEN_MAX_AGE_SECONDS (15 minutes by default) or within 60 seconds of the JWT's expiry. In the org test, a question after 16 idle minutes showed "Salesforce access token refreshed before this request." in the trace, then OK sobject-all · getUserInfo. If Salesforce refuses the refresh token, the user sees "Sign in again" and the cookie is cleared.
Signing out is a same-origin POST that revokes the refresh token, so a link on another site cannot sign anyone out. The org test caught a bug here: the app sent Referrer-Policy: no-referrer, so the browser sent Origin: null on the form POST and the same-origin check refused it, with no revoke. The fix sets the policy to same-origin and accepts Origin: null only with Sec-Fetch-Site: same-origin. After it, signing out dropped Headless Assistant Local's User Count to 0 on External Client Apps → OAuth Usage.
The chat route
POST /api/chat validates the body, adds the new message or the answer to a pending proposal, refreshes the session, and builds the request (web/app/api/chat/route.ts, lines 34–43):
const fresh = await refreshSession(signedIn.session, config);
if ("response" in fresh) return fresh.response;
const client = createAnthropicClient(config);
const params = buildMessageParams({
config,
accessToken: fresh.session.tokens.accessToken,
messages: prepared.messages,
enabledWrite: prepared.enabledWrite,
});
It streams newline-delimited JSON back: text, thinking, tool_call, tool_result and proposal events as they happen, and a final done with token usage and, after an approval, the audit.
Two hosted servers, one Claude request
Every chat turn is one call to buildMessageParams (web/lib/anthropic.ts, lines 175–191):
export function buildMessageParams({ config, accessToken, messages, enabledWrite }: BuildParamsInput): BetaMessageStreamParams {
const params: BetaMessageStreamParams = {
model: config.anthropic.model,
max_tokens: config.anthropic.maxTokens,
system: buildSystemPrompt(config),
messages,
mcp_servers: buildMcpServers(config, accessToken),
tools: [...buildMcpToolsets(config, enabledWrite), buildProposeWriteTool(config)],
thinking: { type: "adaptive", display: "summarized" },
cache_control: { type: "ephemeral" },
betas: [config.anthropic.mcpBeta],
};
if (config.anthropic.effort) {
params.output_config = { effort: config.anthropic.effort };
}
return params;
}
mcp_servers names both servers, sobject-all as salesforce-sobject and the custom Apex server as salesforce-custom, each with the user's access token as authorization_token. One Salesforce access token works for both servers: observed on 2026-10-04, "Who am I signed in as?" gave OK sobject-all · getUserInfo, the health question gave OK custom · getAccountHealth (Watch, 55/100), and no SF_OAUTH_RESOURCE was needed. The pipeline question gave OK sobject-all · soqlQuery: 3 open opportunities this quarter, 295,000 in total, with Claude assuming the calendar quarter. tools holds one mcp_toolset per server plus the client tool propose_write_action. betas becomes the anthropic-beta: mcp-client-2025-11-20 header, and the model defaults to Claude Sonnet 5.5 (claude-sonnet-5-5); ANTHROPIC_MODEL switches to any other Claude model that supports the MCP connector.
A beta connector, tested by you
The Claude API's MCP connector is a beta, and no document shows it paired with hosted MCP servers; the org test above is our evidence, not a guarantee. Day 10 lists the constraints. Ask "Who am I signed in as?" first in your own org.
The tool trace and approve-before-write

The trace panel is your record of what the assistant did. Every mcp_tool_use and mcp_tool_result block becomes a row with the server, the tool, the input and the output or error, alongside the reasoning summary, token refreshes, usage and approvals.
The approval step has three parts.
1. Every tool is off unless listed. The toolset builder from Day 10 fails closed: each toolset starts with default_config: { enabled: false } and enables only the configured read tools. What reaches Anthropic on the turn where Neha asks for a task, and on the turn after she approves it, looks like this:
import type { BetaMCPToolset } from "@anthropic-ai/sdk/resources/beta/messages/messages";
// sobject-all: everything off, then the six read tools on. No write tool is ever listed here.
const sobjectReads: BetaMCPToolset = {
type: "mcp_toolset",
mcp_server_name: "salesforce-sobject",
default_config: { enabled: false },
configs: {
getObjectSchema: { enabled: true },
soqlQuery: { enabled: true },
find: { enabled: true },
getUserInfo: { enabled: true },
listRecentSobjectRecords: { enabled: true },
getRelatedRecords: { enabled: true },
},
};
// Turn 1: Neha asks for a task. Only read tools are on; Claude can only propose.
export const proposalTurn: BetaMCPToolset[] = [
sobjectReads,
{
type: "mcp_toolset",
mcp_server_name: "salesforce-custom",
default_config: { enabled: false },
configs: { getAccountHealth: { enabled: true } },
},
];
// Turn 2: she clicked Approve. createFollowUpTask is on too, for this one request only.
export const approvedTurn: BetaMCPToolset[] = [
sobjectReads,
{
type: "mcp_toolset",
mcp_server_name: "salesforce-custom",
default_config: { enabled: false },
configs: { getAccountHealth: { enabled: true }, createFollowUpTask: { enabled: true } },
},
];
Failing closed matters because tool names can change under you: a tool re-added in Setup comes back as CreateFollowUpTaskToolapex_CreateFollowUpTaskTool (Day 12). A denylist would leave that renamed write tool enabled. Here it stays off, and Claude cannot even propose it, because it is in no list.
2. The model proposes instead. A client tool is the only way to ask for a change, and its enums allow only configured write tools (web/lib/anthropic.ts, lines 86–114):
export function buildProposeWriteTool(config: AppConfig): BetaTool {
const writeTools = serverNames(config).flatMap((server) => writeToolsFor(config, server));
return {
name: PROPOSE_WRITE_TOOL,
description:
"Propose a change to Salesforce data for the user to approve. This is the only way to create, update or delete " +
"records: every write tool stays disabled until the user approves the exact action proposed here. Look up any " +
"record Ids you need first, call this once per change, then stop and wait for the result.",
input_schema: {
type: "object",
properties: {
server: { type: "string", enum: serverNames(config), description: "MCP server that owns the write tool." },
tool: { type: "string", enum: writeTools, description: "Write tool you will call once approved." },
arguments: {
type: "object",
description: "The exact arguments you will pass to the write tool.",
additionalProperties: true,
},
summary: {
type: "string",
description:
'One sentence a business user can check, e.g. "Create a High priority task \'Send renewal quote\' on Acme Corp due 2026-10-09."',
},
},
required: ["server", "tool", "arguments", "summary"],
},
eager_input_streaming: true,
};
}
When Claude calls it, the turn ends with stop_reason: "tool_use", the way any client tool call does, and the browser shows the card.
There is a catch the org test found. The connector hides disabled tools from the model, schemas included, so Claude has to guess a write tool's arguments. Its first proposal used accountId, the getAccountHealth parameter. After Approve, the trace said "Write tool enabled for this request only: salesforce-custom / createFollowUpTask", then "Approved createFollowUpTask, but no write tool was called", and Claude proposed again with recordId. The fix is an argument hint in the system prompt for each write tool, ending "It uses recordId, not accountId", plus an instruction to check field names with getObjectSchema before proposing an sobject-all write. After the fix, "send the case update next Friday" took one Approve, and the task was created exactly as approved.

3. Approve enables exactly one tool for one request. Clicking Approve sends the decision back. The server checks the proposal names a real server and a configured write tool, then answers the pending tool call (web/lib/chat.ts, lines 128–135):
enabledWrite = { server: proposal.server, tool: proposal.tool, arguments: proposal.arguments };
content.push(
toolResult(
target.id,
`APPROVED by the user. Call ${proposal.tool} on ${proposal.server} now, exactly once, with exactly these ` +
`arguments: ${JSON.stringify(proposal.arguments)}. Then report the outcome.`,
),
);
enabledWrite switches that one tool on for that one request. Reject sends "REJECTED by the user" with every write tool still off; in the org test the trace showed REJECTED proposal · createFollowUpTask, Claude replied "I haven't created the 'Send contract draft' task", and no Task existed. A new message instead of a decision closes the proposal as "NOT APPROVED".
Then the audit. After an approved turn the server compares every write call that actually ran with what Neha approved (web/lib/chat.ts, lines 192–210):
export function auditWrites(messages: BetaMessageParam[], approved: ApprovedWrite, config: AppConfig): WriteAudit {
const executed: WriteAudit["executed"] = [];
for (const message of messages) {
if (typeof message.content === "string") continue;
for (const block of message.content) {
if (block.type !== "mcp_tool_use" || !isWriteTool(config, block.server_name, block.name)) continue;
executed.push({
server: block.server_name,
tool: block.name,
input: block.input,
matchesApproval:
block.server_name === approved.server &&
block.name === approved.tool &&
stableStringify(block.input) === stableStringify(approved.arguments),
});
}
}
return { approved, executed };
}
One matching call gives "Executed exactly as approved"; anything else gives "Check this write" with the number of calls that differ. In the org test the approved Task showed the signed-in user as Created By.
Be clear about what this guarantees. The MCP tool calls run inside Anthropic's API, so the app cannot intercept a single call. The guard is the tool configuration on each request, plus an audit after the fact. On the approved request the model could still call the enabled tool twice or with different arguments; the audit would flag it, and createFollowUpTask would return the existing task. The approval is enforced by what the request allows, not by what the prompt asks.
Hardening checklist
The repository is a local demo and says so: no rate limiting, no multi-user hardening, "Do not deploy it as is." Before anything like it reaches real users, work through this list from Day 11 and Day 14:

Identity
- One External Client App per client; the assistant does not share Claude's app.
- Permitted Users is Admin approved users are pre-authorized, gated by a permission set; never a self-authorization option.
- Refresh token policy: Expire refresh token after specific time, 30 days or less (there is no "valid until revoked" option).
- In production, the app requires a client secret (
SF_CLIENT_SECRET); the local setup has none. - You know how to revoke: External Client Apps → OAuth Usage, then click the app's user count.
Tools
- Toolsets fail closed, and the read and write lists are checked against each server's Tools tab after each Salesforce release.
- Read-only workflows run on
sobject-reads; business rules live in custom Apex tools withwith sharingand user-mode queries. - The custom server exposes only the tools the assistant needs.
Tokens and logs
- The access token is refreshed at least every 15 minutes; the refresh token never leaves the server.
- Never run with
ANTHROPIC_LOG=debugin production, because the SDK then logs request bodies that contain the user's Salesforce token. - Your logs hold tool names and durations, not inputs, results or headers.
- You accept that the MCP connector is not covered by zero data retention, or you do not send that data through it.
Operations
- You watch the org's daily API usage and, if your org generates API Total Usage logs, rows with
API_CLIENT_CATEGORY = SALESFORCE_HOSTED_MCP. - You have budgeted for Flex Credits usage in production, and you have a plan for Salesforce's agent registration ("Targeting November"; announced, not shipped).
- You add rate limiting and per-user limits before a second user signs in.
What can go wrong
| Symptom | Likely cause | Fix |
|---|---|---|
| Salesforce's login page rejects the client ID | The External Client App has not propagated, or the key is wrong | Wait the full 30 minutes; copy the Consumer Key again |
redirect_uri_mismatch |
SF_CALLBACK_URL differs from the app's callback |
Match scheme, host, port and path; localhost is not 127.0.0.1 |
| Every tool call returns 401 | No JWT tokens or PKCE, beta-era scopes, or SF_LOGIN_URL not matching the server |
Recheck Day 5. SF_OAUTH_RESOURCE is an untested fallback; the org test didn't need it |
| Custom tools missing from the trace | Apex not deployed, server not active, or permission set missing | Redeploy, activate HeadlessAssistantTools, assign Headless_Assistant_User |
| Claude says a tool is unavailable | The tool isn't in a read or write list, often because it was re-added in Setup under a generated name | Add the name from the server's Tools tab to the right read or write list |
| "Approved …, but no write tool was called" | Claude guessed the hidden write tool's arguments | Update to the current repository, which adds argument hints |
| Sign out shows "Cross-origin requests are not allowed." | A clone from before the Referrer-Policy fix | Pull the current repository |
| "Sign-in failed: OAUTH_APP_ACCESS_DENIED" | The user lacks the gating permission set | Assign Headless_Assistant_User if they should have access |
| Trace says "Check this write" | The model called the approved tool twice or changed arguments | Check the record; the task tool is idempotent; reject and ask again |
| "Your Salesforce session ended" | Refresh token expired, rotated elsewhere or revoked | Sign in again; check the refresh token policy |
Security note: what runs as whom
In Salesforce, every tool call runs as Neha, with her permissions and sharing, and the audit trail names her. At Anthropic, the request runs under your API key and carries her short-lived access token. The only person who can turn a proposal into a write is the one who clicks Approve.
Today's checklist
- The Apex tools deployed with their tests passing, and
HeadlessAssistantToolsis active. - I signed in to the app with PKCE, and the header shows my user.
- "Who am I signed in as?" returned
getUserInfothrough the Claude API's MCP connector. - "How healthy is the Acme Global Tech account?" used
getAccountHealthon the custom server. - A follow-up task needed my approval, and the trace said "Executed exactly as approved".
- Rejecting a proposal created nothing.
- After Sign out, the app's User Count on External Client Apps → OAuth Usage dropped.
- The Task in Lightning shows me as Created By.
- I went through the hardening checklist and know what the demo still lacks.
Frequently asked questions
Can I deploy this assistant to production as it is?
No. It is a local demo with no rate limiting and no multi-user hardening. Use it as a reference for the patterns (server-side tokens, per-request tool configuration, audits and least privilege) and build the operational parts from Day 14 around them.
Does the approval step stop prompt injection?
It limits what an injection can do. Text in a record can still mislead an answer, but it cannot trigger a write, because no write tool is enabled until a person approves one exact call.
Which parts of the stack are beta?
Two: the Claude API's MCP connector and the DX MCP Server. The hosted servers, custom servers included, and External Client Apps are generally available.
Can I use sobject-reads instead of sobject-all?
Yes, and for an assistant that mostly reads, you should. Point SF_MCP_SOBJECT_URL at the sobject-reads URL: that server has no tools that change data, so the only write left is the custom server's createFollowUpTask, behind the approval step.
What's next
That is the series. In fifteen days you went from "what is this?" to an assistant that reads and writes Salesforce data safely, as the person using it. Here is the whole route, one line per day:
- Day 1: What Is Salesforce Headless 360 and Why Does It Matter? The map, the AIforce rename, GA versus beta.
- Day 2: Headless 360 Architecture Explained. One request from client to platform, and what runs as whom.
- Day 3: MCP Fundamentals: Client, Server, Tools & Resources. The protocol, its lifecycle and a first local server.
- Day 4: Enable & Configure the Salesforce MCP Server. Activating hosted servers and choosing the smallest one.
- Day 5: OAuth 2.0, PKCE & External Client App Setup. The app every client signs in through.
- Day 6: Connect an AI Client to Salesforce via MCP. Claude and Claude Code, connected.
- Day 7: Explore Salesforce MCP Tools & sobject-all. Every tool, and the beta Headless 360 MCP Server.
- Day 8: Read Salesforce Data Using Natural Language. Prompts that produce answers you can verify.
- Day 9: Create & Update Salesforce Data Safely. Propose, confirm, execute, verify.
- Day 10: Build Your First AI + Salesforce Workflow. The read-only meeting brief.
- Day 11: Security Architecture: OAuth, Permissions & Least Privilege. The layers around it all.
- Day 12: Custom MCP Tools + Apex/Flow Business Logic. Your own rules as tools.
- Day 13: Headless 360 + Agentic AI Architecture. Patterns, Agentforce and the two ways to ship UI.
- Day 14: Production Architecture: Governance, Monitoring & Error Handling. Running it for real.
- Day 15: Build a Complete Headless Salesforce AI Assistant. Today.
This series connected an assistant from outside Salesforce. The next one builds agents inside it. 30 Days of Agentforce Development is coming next: agent anatomy, custom actions in Flow and Apex, prompt templates, testing and deployment, the Agent API, and a two-part capstone that ships a support agent. Build something with what you learned here, and bring it along.
Sources
Verified against the sources below on September 30, 2026. Salesforce ships Headless 360 changes often: check the linked docs if a screen looks different.
- MCP connector — Claude API docs: mcp_servers, mcp_toolset configs, authentication, data retention
- Security Best Practices (Salesforce Hosted MCP Servers)
- Create an External Client App — settings and production hardening options
- Expose Custom Apex as a Hosted MCP Tool for Agents
- How to Secure Salesforce Hosted MCP Servers
- Salesforce DX MCP Server (README)
- TypeScript SDK — logging levels and what debug logs
- salesforce-headless-360 (companion repository)
Everything from today on one page. Tap to zoom, or download it for later.
All 15 days in this series
- Day 15Build a Complete Headless Salesforce AI Assistant







Comments
Loading comments...