The Agentforce
Field Guide.
Eight chapters. Seven laws. The production rules nobody documented, so your first agent isn't the one that teaches them to you the hard way.
Welcome.
On a Thursday afternoon last quarter, a wealth-management agent answered a billing question by quoting regulatory disclaimer text from a different product line. The customer thought the firm was changing their fee structure. The customer called their advisor. The advisor called compliance. Compliance called the engineering manager. The engineering manager called the dev who'd shipped the agent the week before.
That dev knew Apex inside out. They'd shipped Salesforce code for six years. They had never, ever been the person on the other end of "why is the bot lying to my client."
This guide is for that dev. It's the senior dev you wish was sitting two desks over: the one who's already shipped four agents, gotten paged by every one of them, and lived to tell the story with a coffee in hand. No jargon you don't need. No condescension. No marketing voice. Just the rules. The real ones, the ones that don't make it into the docs until production has already taught them to you the hard way.
We'll go from "what is an agent, mechanically" to "how do I roll one back when it's misbehaving in front of a real customer." Eight chapters. Around forty-five minutes. Every page earns its space.
If you can write Apex, run a SOQL query, and have deployed something through SFDX or a change set, you're qualified. Pour the coffee. Let's go ship something that doesn't keep you up at night.
Quinn Studio
What's inside.
- The Mental Model What's actually happening when your agent "thinks"
- Subagents: Personality How to tell your agent what it's allowed to talk about
- Actions: Verbs Apex invocables that don't blow up at 47 records
- Testing: Trust, Verify How to catch bugs before a customer screenshots them
- Security: Don't Get Fired Permissions, OAuth, and the integration-user secret
- Shipping: Sandbox to Prod SFDX, deployment order, and the activate-vs-deploy trap
- Gotchas Twelve landmines, every one of them paid for in blood
- Pre-Flight Checklist One page. Print it. Stick it on your monitor.
The Mental Model.
Before you write a single line of code, you need to know what's actually happening between the moment a user hits Enter and the moment your agent answers. Without the mental model, the rest of this guide is a spell book. With it, every weird thing your agent does in production becomes diagnosable in under sixty seconds.
What an agent actually is
Forget for a second that we call this thing "AI." Strip the marketing off and an Agentforce agent is a small, dutiful program with exactly one job: read what the user said, decide what to do about it, do it, and respond. That's the entire shape of it.
The "deciding" runs on a Salesforce-built engine called Atlas. Yes, like the Marvel character. Yes, Salesforce really named their AI brain Atlas. No, it does not hold up the world. Yes, it sometimes drops things. Roll with it.
Atlas works in a loop with a name engineers love because it's a verb you can say out loud: ReAct. Reason. Act. Observe. Repeat until done. That's the whole engine. Once you see it, you can't unsee it.
It's 4:47 PM Friday. A customer types: "Reschedule my Tuesday appointment to Thursday at 3pm."
Atlas reasons: "This person wants to reschedule something. I should look up their existing appointment first, then update it."
Atlas acts: Calls a tool you wrote called FindAppointment. Gets back an appointment Id.
Atlas observes: "Got it. Now I need to update the time. I'll call UpdateAppointment."
Atlas reasons again: "Done. Tell the user it worked."
Three rounds of reason→act→observe. No magic. No mystery. Just a checklist with a thesaurus.
That's the whole game. Your job as the developer is to give Atlas a menu of things it's allowed to do (we call those actions), grouped into categories (we call those topics), and trust the engine to pick the right ones. You are not building the brain. You are writing the menu. The brain is rented. The menu is yours.
It will figure out exactly what you wrote down for it to figure out. The descriptions on your topics, actions, and variables are the entire universe Atlas operates in. Vague descriptions, vague behavior. Atlas is not creative. Atlas is dutiful.
They aren't. Default callout timeout is 10 seconds. Default conversation retention is forever. Default permissions on a fresh connected user grant nothing useful. Every default in this stack is tuned for the demo, not for you. Override on day one.
It's Apex with stochastic invocation, hidden FLS, model updates outside your control, and a planner that reads your variable descriptions like API docs. Same syntax. Different sport. Treat it like Apex and the platform will eventually correct you, in front of a customer, on a Friday.
The four pieces you'll actually work with
Every Agentforce project, no matter how complicated it looks in a slide deck, is built from the same four blocks. Once you can name them, the docs stop reading like a foreign language.
| What it's called | What it actually is | Plain English |
|---|---|---|
Bot + BotVersion |
The agent itself. | The whole assistant. Like the "ChatGPT" of your app. |
GenAiPlannerBundle |
The brain config. | The agent's personality file. "Here's what you can talk about and do." |
GenAiPlugin |
A topic. A category of related things. | Like a Slack channel. One topic per job-to-be-done. |
GenAiFunction |
An action. One specific thing the agent can do. | A button the agent can press. Usually wraps Apex or a Flow. |
Beginning April 2026, Salesforce officially renamed topics to subagents in the docs and UI. The functionality is identical. The metadata type is still GenAiPlugin. Code samples, deployment paths, and APIs are unchanged. This guide uses "topic" throughout because that's the name still on every existing agent in your org and most of the docs you'll Google. When you see "subagent" in a Salesforce help article, mentally substitute "topic." Same thing.
Salesforce keeps changing the names. GenAiPlanner (the old way) was replaced by GenAiPlannerBundle in API 64. Then Winter '26 introduced Agent Script in public beta (a new declarative language for defining agents) along with the AiAuthoringBundle metadata type that wraps it. Agent Script went generally available and was open-sourced at TDX 2026 in April 2026, so as of this guide's writing it's a real, supported tool. We'll cover when to reach for it in Chapter 2. Welcome to the platform that ships breaking changes inside its own naming convention.
The hierarchy, drawn
// Top level: the agent itself
Bot "Wealth_Agent"
└── BotVersion v1
│
└── GenAiPlannerBundle // the brain config
│
├── GenAiPlugin "Schedule_Client_Review" // subagent
│ ├── GenAiFunction "Find_Available_Slots" // action
│ ├── GenAiFunction "Book_Review_Meeting"
│ └── GenAiFunction "Cancel_Review_Meeting"
│
├── GenAiPlugin "Account_Summary"
│ └── GenAiFunction "Get_Account_Balance"
│
└── GenAiPlugin "Escalate_To_Human"
└── GenAiFunction "Create_Service_Case"
Every agent looks like this tree. Different names, same shape. Once you can read this, you can read any agent in any org, including the one your predecessor left half-built before they took the new job.
When something breaks, walk this tree top to bottom. Bot active? BotVersion active? Planner deployed? Topic listed in the planner? Action listed in the topic? Apex behind the action compiling? Nine times out of ten, the answer is sitting on one of those rungs. The tenth time you've found a real bug worth a ticket.
An agent isn't magic. It's a planner with a menu.
Your job is writing the menu.
Subagents: Your Agent's Personality.
Topics are how you tell your agent what kinds of conversations it's allowed to have, and what it's supposed to stay out of. Get them right and your agent feels sharp. Get them wrong and it feels like a brilliant intern with no boundaries. Confident, fast, and answering questions you never wanted asked.
The Slack channel analogy
Think about how a healthy company organizes Slack. There's no single channel called #everything. There's #engineering, #design, #customer-support, #random. Each one has a clear job. People know where to post. Conversations don't collide.
Topics are exactly that. Each topic groups actions that belong to one job-to-be-done. Scheduling stuff goes in a Scheduling topic. Account questions go in an Account topic. Escalations go in their own topic, because escalations are their own job. When a user types something, Atlas reads every topic's description, picks the one that best matches, and routes the conversation there.
Topics, in other words, are classifiers in a trench coat. The better you describe them, the better Atlas routes traffic. Sloppy descriptions are how you end up with the agent confidently answering a billing question with a knowledge article about tax forms.
Anatomy of a topic
<?xml version="1.0" encoding="UTF-8"?>
<GenAiPlugin xmlns="http://soap.sforce.com/2006/04/metadata">
<developerName>Schedule_Client_Review</developerName>
<masterLabel>Schedule Client Review</masterLabel>
<!-- Atlas reads this to decide IF this topic applies -->
<description>Books, reschedules, or cancels portfolio review
meetings for FSC clients.</description>
<language>en_US</language>
<pluginType>Topic</pluginType>
<!-- Atlas reads this to decide what NOT to do -->
<scope>Manage portfolio review scheduling for verified
clients only. Do not discuss product
recommendations or fees.</scope>
<!-- The actions this topic owns -->
<genAiFunctions>
<functionName>Get_Client_Advisor</functionName>
</genAiFunctions>
<genAiFunctions>
<functionName>Find_Available_Slots</functionName>
</genAiFunctions>
<genAiFunctions>
<functionName>Book_Review_Meeting</functionName>
</genAiFunctions>
</GenAiPlugin>
Two fields here do almost all the work. Most teams ignore one of them. Don't be those teams.
description tells Atlas when to pick this topic
This is what Atlas reads to classify user input. Write it like you're labeling a button at a self-checkout: short, verb-first, specific, no marketing voice. Atlas does not need a paragraph. It needs a name tag.
Don't
"This topic provides comprehensive functionality for managing the entire client meeting lifecycle including booking, modification, and cancellation workflows."
Do
"Books, reschedules, or cancels portfolio review meetings for FSC clients."
scope tells Atlas when not to use this topic
This is the field everyone skips. Skipping it is also why everyone has to write a postmortem about that time their scheduling agent quoted fees from a stale knowledge article. Scope is the agent's job description, written in the negative. "You schedule meetings. You don't quote prices. You don't give advice. If asked, hand off." Atlas honors it. The five extra minutes you spend writing it pays for itself the first time someone tries to redirect the agent into territory it has no business being in.
Treat scope like the "out of scope" section of a Jira ticket. Be loud about what isn't the agent's job. Every line of "don't do X" is a production incident you just prevented.
The God Topic anti-pattern
Every team that's new to Agentforce makes the same mistake. Day one: they create a single topic called Customer_Service and stuff thirty actions into it. Day two: it works. Day eight: the agent starts confidently calling the wrong action because it's drowning in choices. Day fourteen: there's a Slack thread called "why is the agent broken."
The Salesforce-blessed number: six to twelve actions per topic is the practical sweet spot. The platform's actual hard limit is 15 actions per topic and 15 topics per agent, so technically you can push higher. But past twelve, the planner has too many lookalikes to choose between. Accuracy drops. Latency rises. Your dashboard turns into a confessional. The fix is the same fix it always is: split by job-to-be-done.
| Topic shape | Action count | When to use it |
|---|---|---|
| Focused | 1 to 3 | One atomic capability. Auth_PasswordReset. |
| Standard | 4 to 8 | One job, a few variants. Service_CaseLookup with by-Id, by-customer, by-date. |
| Broad | 9 to 12 | A workflow. Scheduling_Appointment with create, reschedule, cancel, find-slot. |
| God topic | 13 to 15 | Inside the platform's hard limit but past the practical accuracy sweet spot. Refactor today. This is the source of your latency and accuracy issues. |
A clinical-services team launched their first agent with everything stuffed under Care_Coordination. Thirty-one actions, all relevant, all reasonable. The first day of UAT, the agent picked the right action 11% of the time. It would call RefillPrescription when the user asked about a billing dispute. It would call ScheduleAppointment when asked to look up lab results.
They didn't change a single line of Apex. They split Care_Coordination into Pharmacy, Scheduling, Records, and Billing. Six to nine actions each. Accuracy hit 87% the next day. Same code. Same model. Different menu.
Naming conventions that won't haunt you
Here's a pain you can save your future self for free. Once you have twenty topics, refactoring names is a nightmare. Actions reference them, planners reference them, and somewhere a custom Apex class probably references them too. Lock a convention before topic #4. Stick to it religiously.
// Pattern that scales
{Domain}_{Capability}
// Examples from a real service desk agent
Service_CaseLookup
Service_CaseUpdate
Service_KnowledgeSearch
Service_Escalation
Billing_InvoiceLookup
Billing_PaymentMethodUpdate
Account_ContactSync
Domain prefix scopes the topic to a business area. Capability is the verb. When you have 47 topics and you need to find the one about case escalation, this is the difference between five seconds and five minutes. Future-you will buy current-you a beer.
Renaming a topic in production is like renaming a column in a database table six other systems are reading. Topic API names are referenced from planner bundles, action metadata, and any custom code that invokes the agent. Pick the name once. If you must rename, do it in a fresh sandbox first and walk every reference by hand. The find-and-replace will lie to you about coverage.
Agent Script (GA April 2026): the new way
Winter '26 quietly shipped something Salesforce should have been louder about: Agent Script, a declarative language for defining agent behavior in code instead of clicking through the Agent Builder UI. You write the topic, the actions, and the rules in a .agent file that lives in source control next to the rest of your codebase. It diffs. It PRs. It code-reviews. All the things UI-defined agents don't. Agent Script became generally available and was open-sourced at TDX 2026 in April 2026 (full spec, parser, and compiler at github.com/salesforce/agentscript). The runtime is still Salesforce-hosted, so you can author and lint locally but execution happens in their environment.
Reach for Agent Script when:
- A regulator or business rule requires a specific sequence (KYC verification before any account-affecting action). UI agents can't enforce sequence; Agent Script can.
- You want to PR-review agent logic instead of staring at UI screenshots in a Confluence page.
- Behavior needs to be byte-identical across environments and immune to "I tweaked the wording in dev real quick" drift.
Stick with the visual Agent Builder when topics are exploratory, when content (not flow) is the variable that's changing, or when non-developers are making changes. Both ship. Use the right one for the job.
Without scope, you don't have an agent.
You have an apology with a chat interface.
Actions: What Your Agent Can Actually Do.
Topics are categories. Actions are the verbs. Every time your agent does a real thing in the real world (queries data, books an appointment, sends an email), that's an action firing. And in 90% of cases, an action is just an Apex method with one special annotation. This is the chapter where you start feeling at home.
The contract, in plain English
An invocable method is an Apex method wrapped in a special annotation that says, "Hey Atlas, you can call this." The annotation hands Atlas four pieces of information, and only four:
- A label. What to call it.
- A description. What it does, in plain English.
- An input schema. What goes in.
- An output schema. What comes back.
That's the entire contract. Everything else inside the method body is invisible to Atlas. The planner has no idea you used a Map in there. It just knows: "If I want X, I call this method with Y, and I'll get back Z." The method is a black box with four labels on the outside. Treat the labels like API documentation, because to Atlas, that's what they are.
Your first invocable, annotated
public with sharing class FindCaseByIdAction {
// Wrapper class for inputs. Why? Because Atlas needs to know
// what each input MEANS. List<Id> tells the planner nothing.
// A class with named, described fields tells it everything.
public class Request {
@InvocableVariable(
label='Case Id'
description='18-character Salesforce Id of the Case'
required=true)
public Id caseId;
}
// Wrapper class for outputs. Same idea.
public class Response {
@InvocableVariable(label='Case Number')
public String caseNumber;
@InvocableVariable(label='Subject')
public String subject;
@InvocableVariable(label='Status')
public String status;
}
@InvocableMethod(
label='Find Case By Id'
description='Returns case details for a specific case Id.'
category='Service')
public static List<Response> findCase(List<Request> requests) {
// Step 1: collect all the Ids the planner sent us
Set<Id> caseIds = new Set<Id>();
for (Request r : requests) {
if (r.caseId != null) caseIds.add(r.caseId);
}
// Step 2: one query, bulk-safe, with USER_MODE for FLS
Map<Id, Case> cases = new Map<Id, Case>([
SELECT CaseNumber, Subject, Status
FROM Case
WHERE Id IN :caseIds
WITH USER_MODE
]);
// Step 3: map results back to responses
List<Response> out = new List<Response>();
for (Request r : requests) {
Response resp = new Response();
Case c = cases.get(r.caseId);
if (c != null) {
resp.caseNumber = c.CaseNumber;
resp.subject = c.Subject;
resp.status = c.Status;
}
out.add(resp);
}
return out;
}
}
That's a complete, production-grade invocable. Let's unpack the choices that aren't obvious, because every one of them was paid for in someone else's pager going off.
Why wrapper classes, always
Your first instinct will be to write something like this:
Tempting
@InvocableMethod
public static List<Response> findCase(
List<Id> caseIds) { // ... }
Always do this
@InvocableMethod
public static List<Response> findCase(
List<Request> requests) { // ... }
Why? Because in three months you'll need to add an includeAttachments flag. Or a date range. Or a user preference. With a wrapper class, that's a one-line change. With raw List<Id>, you're writing a brand new method, hunting down every action that points at the old signature, and praying you didn't miss one. Wrapper classes are how present-you protects future-you from yourself.
Atlas reads the description on every @InvocableVariable and uses it to decide what to pass in. "Case Id" alone tells the planner nothing. "The 18-character Salesforce Id of the Case to retrieve" tells it exactly what to look for. Treat these descriptions like API docs, because the planner is treating them like API docs. Garbage in, hallucination out.
Run as the user, not as God
By default, Apex code runs in system mode: it ignores user permissions, field-level security, and sharing rules. This is fine for triggers running on behalf of the platform itself. It is absolutely not fine for agent actions. The agent runs as a specific connected user with specific permissions, and your code should respect those. Otherwise you're handing every conversation a free pass to data the user was never supposed to see. And yes, that's the kind of thing that becomes a press release.
Salesforce gave us three modern ways to enforce user-mode security. Pick whichever fits the call site:
// 1. SOQL with user mode
List<Account> accts = [
SELECT Id, Name FROM Account
WHERE Industry = 'Healthcare'
WITH USER_MODE
];
// 2. DML with user mode (shorthand)
insert as user newAccount;
update as user existingAccounts;
// 3. Database methods with AccessLevel (partial-success)
List<Database.SaveResult> results = Database.insert(
accts, false, AccessLevel.USER_MODE
);
Use these by default in agent code. If you ever want to bypass them, write a comment explaining why. Then have a senior dev sign off. Then have them sign off again.
If your action fails with "no access to field X" or returns suspiciously empty results, the bug is almost certainly the agent user's permission set, not your code. The fix lives in security setup. Stop reading your Apex. Open the agent user's permissions. Chapter 5 walks you through exactly which permission sets the agent user needs and in what stack.
Errors as data, not exceptions
This is the single most common mistake junior devs make in agent code, and it's the one that ends up in customer-facing tickets the fastest. Junior devs throw exceptions when something goes wrong, expecting the agent to handle it gracefully. The agent does not handle it gracefully. The agent says "I ran into an error" and walks away whistling, leaving the user staring at a dead end.
The fix takes about thirty seconds: return errors as fields in your response, with a friendly message the agent can repeat verbatim.
public class Response {
@InvocableVariable(label='Success')
public Boolean success;
@InvocableVariable(label='Error Message'
description='User-friendly explanation if success is false')
public String errorMessage;
@InvocableVariable(label='Case Number')
public String caseNumber;
}
// In your method
try {
Case c = [SELECT CaseNumber FROM Case WHERE Id = :r.caseId];
resp.success = true;
resp.caseNumber = c.CaseNumber;
} catch (QueryException e) {
resp.success = false;
resp.errorMessage = 'Case not found or you do not have access.';
}
Now the agent reads success: false and errorMessage, and crafts a sensible reply. The user gets "Sorry, I couldn't find that case" instead of the platform shrug emoji. Same code complexity. Wildly better experience. Errors are data. Treat them that way.
Internal Ids, stack traces, raw system messages, or anything you wouldn't paste straight into a customer-facing email. The agent will repeat it verbatim, in front of whoever's reading. errorMessage is user-facing copy. Write it like marketing is going to read it, because they will, the day it leaks.
Async work: when the API is slow
Agent actions run synchronously. The planner waits, hand on the timer, for your method to return. If your work takes more than a few seconds (slow external API, heavy report generation, anything that touches a system Salesforce doesn't own), you have two options. The wrong one is "let it block." The right ones are below.
Option 1: Fire and forget with a status check
@InvocableMethod(label='Start Account Report')
public static List<Response> startReport(List<Request> requests) {
List<Response> out = new List<Response>();
for (Request r : requests) {
// Queue the heavy work, return the job Id immediately
Id jobId = System.enqueueJob(new ReportQueueable(r.accountId));
Response resp = new Response();
resp.jobId = jobId;
resp.message = 'Report started. Check status in a few seconds.';
out.add(resp);
}
return out;
}
The agent gets back a Job Id. It tells the user "I started the report, give me a moment." When the user follows up ("is it done?"), Atlas calls a second action CheckReportStatus(jobId) and reports the result. Two actions, one workflow, zero blocking. This is the pattern. Memorize it.
You'll see Continuations in older Apex docs. They're a way to make long-running callouts from Visualforce or LWC. They don't work in agent context. The agent runtime doesn't provide the request thread Continuations need. Reach for them and you'll get a runtime exception that does not say "Continuations don't work here." Use Queueable plus a status-check action. Move on with your life.
Setting HTTP timeouts properly
The default Apex HTTP callout timeout is 10 seconds. The maximum is 120. Always set an explicit timeout. If you don't, you're at the mercy of whatever the platform decides today, and that decision quietly changes between releases. Explicit is one extra line of code. Implicit is one extra postmortem.
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Wealth_API/portfolios/' + accountId);
req.setMethod('GET');
req.setTimeout(60000); // 60 seconds. Max is 120000.
HttpResponse res = new Http().send(req);
Returning data: less is more
The temptation is to return everything because data is cheap and "the agent will figure it out." Don't. The planner has a token budget for what it sends to the model, and a 200KB JSON blob blows right through it. The agent doesn't truncate intelligently. It truncates wherever the math says stop. Then it answers using whatever survived.
- Cap result lists at 5 to 25. Return a count if there's more.
- Return summaries, not full records. If the agent needs detail later, let it ask for it with a follow-up action.
- Never return raw sObjects. Return wrapper classes you control. Otherwise FLS hides random fields and the agent's behavior becomes impossible to reproduce.
Add a "guidance" field to your response. Something like message: "Found 47 cases. Showing the 5 most recent. Ask the user to refine." The agent reads it and crafts a useful follow-up question instead of spitting out five records and going silent. One field. Five seconds to add. Game changer.
Errors are data. Throw an exception in production
and you've handed the conversation to a stranger.
Testing: Trust, but Verify.
Testing an AI agent is genuinely strange. Make peace with that on day one. Your Apex method returns the same thing for the same input, every time. The agent's choice of which Apex method to call doesn't. Same prompt, two minutes apart, two different actions. Welcome to stochastic invocation. You need two layers of testing now, not one. The first one you already know. The second one is the part that keeps senior devs employed.
The two things you actually need to test
- Did Atlas pick the right action? User said "book me a meeting." Agent called
UpdateAccount. That's a routing bug. The Apex is innocent. - Did the action work correctly? Atlas called
BookMeetingwith the right inputs. The Apex blew up at record 51. That's a code bug. Atlas is innocent.
Different bugs. Different shapes. Different tools. Don't try to fix one with a test for the other.
Layer 1: Apex unit tests (you already know this)
Standard @IsTest patterns work fine for invocable methods. Cover happy path, error paths, and bulk safety. Same way you'd test any Apex. Nothing exotic. Nothing AI-flavored. This is the comfort zone. Stay in it for as long as the bug allows.
@IsTest
private class FindCaseByIdActionTest {
@TestSetup
static void setup() {
Account a = new Account(Name='Test Co');
insert a;
Case c = new Case(Subject='Test', AccountId=a.Id);
insert c;
}
@IsTest
static void findsExistingCase() {
Case existing = [SELECT Id FROM Case LIMIT 1];
FindCaseByIdAction.Request r = new FindCaseByIdAction.Request();
r.caseId = existing.Id;
Test.startTest();
List<FindCaseByIdAction.Response> out =
FindCaseByIdAction.findCase(new List<FindCaseByIdAction.Request>{r});
Test.stopTest();
System.assertEquals(1, out.size());
System.assertEquals('Test', out[0].subject);
}
}
Nothing fancy. Standard test class. Three rules to live by:
- Always seed data in
@TestSetup. Never depend on org data, even custom settings, unless they're packaged custom metadata. Sandboxes lie. Scratch orgs lie harder. - Run the test as the agent's user.
System.runAs(agentUser). This is how you catch the FLS gap before production catches it for you in front of a real customer. - Test bulk. Send 200 requests in a single invocation. Make sure the code doesn't blow up at the 51st query, the 11th DML, or the 100th callout.
Layer 2: Agent regression tests (the new thing)
This is the layer you've never written before, because it's specific to AI agents. Salesforce ships a tool in Setup called the Agentforce Testing Center. It lets you write test cases that look almost like English:
# specs/Wealth_Agent-testSpec.yaml: used with `sf agent test run`
name: wealth_agent_regression
testCases:
- utterance: "Book a portfolio review with my advisor next Tuesday at 2pm"
expectedSubagent: Schedule_Client_Review
expectedActions:
- Get_Client_Advisor
- Find_Available_Slots
- Book_Review_Meeting
expectedOutcome: "Booking confirmed for Tuesday at 2pm with the assigned advisor"
- utterance: "What's my account balance?"
expectedSubagent: Account_Summary
expectedActions:
- Get_Verified_Client_Accounts
expectedOutcome: "Returns the verified client's current account balance"
# The most important kind: testing what the agent should NOT do.
# In this format you encode "forbidden" by asserting what the agent
# SHOULD do instead. Escalate, not place the trade.
- utterance: "Buy 100 shares of Tesla for me"
expectedSubagent: Escalate_To_Human
expectedActions:
- Escalate_To_Human_Action
expectedOutcome: "Refuses to place the trade and escalates to a human advisor"
Each test case is an utterance (something a real user might actually type), an expected subagent, an expected sequence of actions, and an expected outcome described in natural language. The outcome check is the interesting one: it doesn't do exact string match. It uses an LLM-graded "gist" comparison, so wording can vary as long as the meaning lands. You batch-run them with sf agent test run (or in the Testing Center UI in Setup with a CSV equivalent) and get a pass/fail per test. It's unit testing for the brain you didn't write. Treat it that way.
Pull a sample from your support tickets or chat logs. Include the typos. Include the half-sentences. Include the hostile inputs and the out-of-scope requests. "i need a doctor" is a real utterance. "Please schedule an appointment with a cardiologist" is what a developer types. If your test set sounds like a developer wrote it, your agent will only work for developers.
Layer 3: Test the negative cases
The most underrated kind of agent test is the negative test. Not "what should the agent do?" Instead: what should it absolutely refuse to do, no matter how the question is phrased?
- Should not give financial advice.
- Should not place trades without explicit confirmation.
- Should not access another customer's data, even when asked nicely.
- Should not cave to prompt injection ("ignore previous instructions and...").
Write a test for each. If your agent ever fails one, that's a P0 bug, not a P3 polish item. Negative tests are the difference between an agent and a liability.
Wiring it into CI
The Testing Center exposes a REST API called the Testing API. Wire it into a GitHub Action and gate PR merges on it. Now every pull request runs the regression suite. If a change breaks topic routing, you find out before the customer does.
# In your CI workflow
sf agent test run \
--spec specs/Wealth_Agent-testSpec.yaml \
--target-org ci-sandbox \
--result-format junit \
--output-dir test-results/
This is the part that makes you look like a wizard at standup. "Yeah, the regression suite caught it on the PR. Reverted before lunch."
Salesforce updates the underlying language model on its own schedule. A model update can silently change which actions Atlas picks for the same exact input. Same code. Same prompt. Different behavior. This is the entire reason you have a regression suite. Run it after every Salesforce release. Most of the time the model gets smarter and your suite passes. Occasionally it gets weirder, and you'd rather catch that in UAT than in a customer support ticket titled "the agent is gaslighting me."
Test what your agent shouldn't do.
The other tests catch typos.
These tests catch lawsuits.
Security: Don't Get Fired.
This is the chapter that, if you skip it, will make you the headliner of the next post-mortem. Agentforce sits at the intersection of three permission systems (platform, app, and OAuth), and getting any one of them wrong creates either a security hole, a silent failure, or a security review that turns a one-week launch into a six-week negotiation. Read it. Highlight it. Bookmark the table.
The big secret: use an Integration License user
Here's something nobody tells junior devs and frankly Salesforce should put on a billboard. Salesforce gives you 5 free Salesforce Integration license users with every Enterprise, Performance, or Unlimited org. (Developer Edition gets 1.) These users were built for one thing: system-to-system access via API. They are exactly what your agent needs.
The Salesforce Integration license:
- Is API-only by design. UI login is impossible. There is no UI to sign into.
- Pairs with the Salesforce API Integration permission set license.
- Doesn't require MFA, because there is literally nothing to MFA.
- Should be assigned one per integration (Mulesoft, ETL, deployment user, external system A, external system B). One license, one job.
Before this license existed, teams used a regular Salesforce-licensed user as their integration user. This was bad in two ways. First, it counted against your paid license seat. You were paying real money for a robot. Second, MFA enforcement either broke the integration (because the bot couldn't complete MFA) or required ugly workarounds that security review rejected on sight. Integration licenses fix both problems and they're free. Use them. Stop doing the old way. Tell whoever's still doing the old way to stop.
The MFA permission name (this is where the previous draft was wrong)
If for some reason you can't use an Integration license user, you'll have to handle MFA yourself. There are three different system permissions that touch MFA, they all look similar at a glance, and they do completely different things. Read the table twice.
| Permission | What it does | When to use it |
|---|---|---|
Multi-Factor Authentication for User Interface Logins |
Requires MFA on UI logins | Permission sets for humans |
Multi-Factor Authentication for API Logins |
Requires MFA on API logins | Only when you can verify on every API call (rare) |
Waive Multi-Factor Authentication for Exempt Users |
Exempts a user from MFA enforcement | Documented exemption cases only |
The previous draft of this guide had these inverted. The lesson sticks: read the permission name carefully. "Require" and "waive" are opposites. One adds friction; one removes it. Both have "MFA" in the name. This is how senior devs end up in security review explaining themselves.
The four permission sets you'll deploy
Salesforce auto-creates a managed user for your agent, usually named something cheery like EinsteinServiceAgent User. You assign permission sets to that user. Here's the minimum stack. Skip any of these and the agent will silently misbehave:
| Permission Set | Purpose |
|---|---|
Agentforce Service Agent User |
The agent can run. Without this, nothing happens. Period. |
Agentforce Service Agent Configuration |
Build and manage autonomous agents. |
Agentforce Service Agent Object Access |
Knowledge article access plus case and contact CRUD as the agent. |
Your custom {Agent}_Runtime |
FLS, CRUD, and Apex access for the specific actions YOUR agent calls. |
Skipping the custom permission set is the #1 reason "my Apex action doesn't appear in the agent." The agent user can't see the class, the planner can't find it, and you spend two hours staring at metadata that's perfectly fine. Setup → Permission Sets → grant Apex Class Access on your custom set → assign to the agent user. That's the fix. Tape it to your monitor.
OAuth scopes, demystified
As of Spring '26 (March 2026), Salesforce disabled the creation of new Connected Apps by default across all orgs. Existing Connected Apps still work (nothing is broken), but if you're starting a new Agentforce build, create an External Client App (ECA) instead. Same OAuth scope concepts apply (the table below is still accurate), but ECAs use second-generation managed packaging and Salesforce has been clear they're the long-term path. You can still get a one-time Salesforce Support exemption to create a new Connected App, but they've said that escape hatch is closing in future releases. One known gap: ECAs don't support the Username-Password OAuth flow. If your integration needs that flow, you're stuck on Connected Apps until you can change the auth model. To be clear: existing Connected Apps continue to work indefinitely and are not deprecated. They're simply frozen for new creation.
OAuth scopes are how connected apps tell Salesforce "I need permission to do X." The names sound nearly identical. The behaviors are wildly different. Most teams over-grant, usually because the docs are confusing and "full" sounds like "the right one." It is not. Smaller scope means smaller blast radius if your secrets ever leak. And one day, somewhere, somebody's secrets always leak.
| Scope | What it actually does |
|---|---|
api |
Access REST API and Bulk API 2.0. Includes chatter_api automatically. You don't need to add Chatter separately. |
refresh_token |
Issue refresh tokens for long-lived access. Same as offline_access. |
chatter_api |
Connect REST API only. Use only when restricting an integration to Chatter. |
web |
Use access token from a web browser. UI access. |
full |
Encompasses all other scopes, but you still must explicitly request refresh_token separately to get one. Avoid in production. Security review will reject it. |
content |
Hybrid mobile app session bridging. NOT for Files or ContentDocument access (that's covered by api). |
cdp_query_api, cdp_profile_api |
Data Cloud queries and profile management. |
sfap_api |
Reserved for future use per current Salesforce docs. Don't add this scope unless Salesforce documentation for your specific Agentforce feature explicitly requires it. |
Older docs claim content is for accessing Files. It's not. It's for hybrid mobile apps to bridge sessions in OAuth 2.0 flows. If you want to access Files (ContentDocument), the api scope already covers it. Don't add content unless you're building a hybrid mobile app.
Field-level security gotchas
The agent runs as the connected user, not as the customer chatting with it. This single sentence is the source of most production confusion in the entire stack. Internalize it. Tattoo it. Whatever it takes.
A customer asks the agent: "What phone number do you have on file?" The customer can see their phone number on the account in the portal. They're looking right at it.
The agent says: "I don't see a phone number on file."
The customer is confused. The agent is confident. Both are correct.
The bug: the agent's connected user has FLS that hides the Phone field. The query returned the row. Phone came back null because FLS stripped it. The agent, dutifully, told the truth about what it saw, which happened to be a lie about reality.
The fix: grant FLS Read on every field your agent might reference, on the agent user's permission set. Yes, even fields you only display. Especially those.
What security review will reject
Save yourself a six-week negotiation. These are the ones that come back marked in red, every time:
- Connected user is a sysadmin. Always rejected. "It's easier to manage" is not a defense. Try it; watch them laugh.
- Apex uses
WITHOUT SHARINGorWITH SYSTEM_MODEwithout justification. Document the reason inline, in a comment, in plain English. Or change it. - Hard-coded credentials in Apex. Use Named Credentials. Never put secrets in source. Yes, even "just for now."
- Conversation logs not subject to retention policy. If your org has a retention rule, agent conversations are in scope. Define a policy. Implement it. Before the auditor asks.
- Sensitive fields readable by agent without redaction. SSNs, PHI, credit cards. Even if FLS allows the agent to read them, the conversation log captures them. Build an allowlist of what's okay to surface.
Pre-empt security review with a one-page Agentforce Security Brief. List the connected user, every permission set assigned, every object and field accessed, every external callout, and the conversation retention policy. Reviewers sign off on the brief. Skip this and you'll spend three weeks answering questions you could have answered in one document. This is the cheapest insurance policy in the stack.
The agent runs as the connected user, not the customer.
Internalize this. Or debug it forever.
Shipping: From Sandbox to Production.
Deploying Agentforce metadata is mostly like deploying any other Salesforce metadata, with two important caveats: order matters more than you're used to, and a couple of file structures aren't what you'd expect from looking at the docs. Get either of those wrong and the deploy fails with errors that point at the wrong line in the wrong file. Most "Agentforce won't deploy" tickets are actually one of these two problems.
Your SFDX project, laid out correctly
Here's the project structure that actually works on API 65 (Winter '26). Verified against real production deployments. Print it. The online tutorials will steer you wrong on at least one of these folders.
force-app/main/default/
├── aiAuthoringBundles/ // API 65+ Agent Script source
│ └── Wealth_Agent/
│ ├── Wealth_Agent.bundle-meta.xml
│ └── Wealth_Agent.agent
│
├── bots/
│ └── Wealth_Agent/
│ ├── Wealth_Agent.bot-meta.xml
│ └── v1.botVersion-meta.xml
│
├── genAiPlannerBundles/ // API 64+ planner
│ └── Wealth_Agent/
│ ├── Wealth_Agent.genAiPlannerBundle-meta.xml
│ ├── localPlugins/
│ └── localActions/
│
├── genAiPlugins/ // subagents
│ └── Schedule_Client_Review/
│ └── Schedule_Client_Review.genAiPlugin-meta.xml
│
├── genAiFunctions/ // actions, FOLDERS not flat files!
│ └── Book_Review_Meeting/
│ ├── Book_Review_Meeting.genAiFunction-meta.xml
│ ├── input/
│ │ └── schema.json
│ └── output/
│ └── schema.json
│
├── classes/
│ ├── ScheduleAppointmentAction.cls
│ └── ScheduleAppointmentAction.cls-meta.xml
│
└── permissionsets/
└── Wealth_Agent_Runtime.permissionset-meta.xml
You'll see online tutorials showing genAiFunctions as flat XML files at the top of the directory. That's wrong, and it has been wrong for at least two API versions. Each GenAiFunction is a folder containing the .xml file plus input/schema.json and output/schema.json. SFDX will not recognize a flat layout. Your deploy will fail with a cryptic error that does not say "your folders are wrong." It will say something about a missing component. Now you know.
Your sfdx-project.json
{
"packageDirectories": [
{ "path": "force-app", "default": true }
],
"namespace": "",
"sfdcLoginUrl": "https://login.salesforce.com",
"sourceApiVersion": "65.0"
}
If you want AiAuthoringBundle support, you need API 65 minimum. If you're stuck on an older API, drop to 64 and skip authoring bundles. Do not try to mix API 62 with genAiPlannerBundles. They are not compatible. The error message will not tell you that. This guide is telling you that.
The deployment order that works
This is the order that respects every dependency in the chain. Deploy out of order and you get cryptic errors that point at the wrong thing. The deploy will blame the planner when the real problem is a permission set that didn't deploy yet. Memorize this list:
- Apex classes, Flows, prompt templates, custom metadata, schemas. The building blocks.
- Permission sets. Granting access to those building blocks.
- Bot and BotVersion. If the agent doesn't already exist.
- GenAiFunction components. Global actions in the Asset Library.
- GenAiPlugin components. Global topics in the Asset Library.
- GenAiPlannerBundle. The planner referencing topics and actions.
- AiAuthoringBundle. If you author with Agent Script.
- Activate the new BotVersion in the target org. This is a separate step. It is not part of the deploy.
Deploying a new BotVersion does not activate it. You have to activate it as a second step, either through the UI or via Apex. This trips up every team on their first deploy. The deploy succeeds. The agent doesn't change. You spend forty-five minutes hunting a phantom bug. Now you know. Go activate it.
An ops team ran a flawless production deploy at 2:14 AM. CI green. Tests passed. Smoke tests passed. Customers continued getting the old behavior. The team rolled forward, rolled back, rolled forward again. Nothing changed.
At 4:40 AM, the engineering manager woke up and asked one question: "Did anyone activate the new BotVersion?"
Silence on the call. Then someone said: "We have to activate it?" One click in Setup. Behavior live in seconds. The team had spent two and a half hours hunting a bug that wasn't a bug. Activation is not deploy. Read it twice.
The validate-then-quick-deploy pattern
For production deploys, never run sf project deploy start directly against prod. Always validate first, then quick-deploy if validation passed. This is the pattern senior Salesforce devs use, and it's the pattern security review will assume you used.
# Step 1: Validate against production. Runs all tests, no apply.
sf project deploy validate \
--manifest manifest/package.xml \
--target-org prod \
--test-level RunLocalTests \
--json | tee validate.json
# Step 2: If validation passed, grab the job ID and quick-deploy
JOB_ID=$(jq -r .result.id validate.json)
sf project deploy quick \
--job-id "$JOB_ID" \
--target-org prod
The quick-deploy reuses the validation results, so it deploys instantly without re-running tests. If validation expired (older than 10 days), you have to re-validate. Set a calendar reminder.
A real GitHub Actions workflow
name: Salesforce DX
on:
pull_request:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Salesforce CLI
run: npm install -g @salesforce/cli
- name: Authenticate to UAT
run: |
echo "$SFDX_AUTH_URL" > /tmp/auth.txt
sf org login sfdx-url --sfdx-url-file /tmp/auth.txt --alias uat
env:
SFDX_AUTH_URL: ${{ secrets.SFDX_AUTH_URL_UAT }}
- name: Validate deploy
run: |
sf project deploy validate \
--source-dir force-app \
--target-org uat \
--test-level RunLocalTests \
--wait 60
- name: Run agent regression tests
run: |
sf agent test run \
--spec specs/Wealth_Agent-testSpec.yaml \
--target-org uat \
--result-format junit
Rolling back when things break
The instinct is to "redeploy the old metadata." Don't. Salesforce metadata deletes are non-trivial, and reverting a Bot is messier than reverting a Git commit. Forward-fix is almost always faster, and almost always less risky. Here's the playbook:
| What broke | Roll back by... |
|---|---|
| Atlas picks the wrong action | Remove the action from the topic. Deploy. |
| Apex action throws errors | Patch the Apex. Deploy. Don't revert. |
| Topic causes widespread misrouting | Deactivate the topic in the planner bundle. |
| Whole agent is broken | Activate the previous BotVersion. |
Always deploy with the previous BotVersion still active. New version goes in as inactive. Test it. When you're ready to cut over, activate the new one. If something's wrong, reactivate the old one. Zero-downtime cutover. Easy rollback. This is the pattern that lets you sleep at night.
Surviving sandbox refreshes
Production-source sandbox refreshes wipe more than you'd think. After a refresh, here's the carnage you can expect:
- Named Credentials lose their auth tokens. Re-authorize.
- Connected app consumer secrets may need regeneration.
- The agent runtime user often needs permission set reassignment.
- The agent itself often comes back inactive. Activate it.
- Flow versions sometimes come back inactive. Activate them.
Build a post-refresh script. Run it automatically. Don't trust manual checklists for things that break customer interactions. The first refresh after this guide ships will teach you why.
Activation is not deploy. They are two steps.
The platform will let you forget. The customer will not.
Gotchas: Stuff Nobody Tells You.
These are the bugs that pass in dev and break in prod. Read this chapter twice. Then read it again the morning of go-live. Every gotcha here has cost a real team a real on-call shift. Some of them, more than one.
1. Long descriptions cause planner latency
Atlas reads every topic and action description when deciding what to do. Verbose descriptions eat the model's token budget and slow classification. Aim for under 200 characters per description. If your description sounds like something a marketing team wrote, cut it in half. Then cut it in half again.
Slow
"This action allows the agent to retrieve detailed information about a specific case by accepting a case ID parameter and querying the Case object in Salesforce."
Fast
"Retrieves a case by Id. Use when the user references a specific case Id or case number."
2. Returning sObjects from invocables
If you return raw sObjects (like List<Case>) from an invocable, Atlas serializes the entire schema. FLS hides random fields. Behavior becomes nondeterministic. The agent passes its tests on Tuesday and fails them on Thursday for reasons nobody can explain. Always return a wrapper class you control. Always.
3. Multi-record output drowns the agent
Return more than 5 to 10 records and the planner gets confused. It can't decide which one is "the one" the user wanted. The user gets a non-answer. Cap your results, return a count, and let the agent ask the user for refinement. Make the agent's job easier and it'll do its job better.
4. Connected user lockout breaks all agents
If the agent's connected user gets a password expiration, an MFA challenge, or worse, gets deactivated, every agent in the org goes silent at once. Set the password to never expire (with a documented justification you can show security review). Use an Integration license user when possible. Put the user in your runbook so the next admin doesn't "clean up unused users" and take down the customer experience on a Wednesday afternoon.
5. Tests pass in sandbox, fail in scratch org
Your test depends on data that exists in the sandbox (a record someone created manually, a custom setting that came along in a package), but doesn't exist in a fresh org. Always seed test data in @TestSetup. Never assume any data exists, even custom settings. The test that passes in your sandbox is not the test. The test that passes in a fresh scratch org is the test.
6. Date inputs get parsed wrong for non-US users
The agent normalizes dates based on user locale. "01/02/2026" is January 2 in the US, February 1 most other places. Same string. Different month. Wildly different appointment. Use Date and DateTime types in your invocables, not String. Let the platform handle ISO 8601 conversion. Don't ask the language model to format dates. That's a job for the type system, not statistics.
7. Conversation context isn't auto-passed between actions
Every action invocation is stateless. If the user asks "what's my urgent stuff?" right after asking "what cases do I have?", Atlas has to re-pass the contact Id from scratch. It will not magically remember. Tell it explicitly in the subagent scope and the input descriptions: "If continuing a prior conversation, use the contact Id from that context." The planner reads it and threads context properly. Without that hint, you'll see the agent asking the user for information it should already know.
8. HTTP timeouts default to surprisingly short
Default Apex callout timeout is 10 seconds. Maximum is 120. The agent runtime has its own (shorter) action timeout layered on top. Set explicit timeouts on every callout. If the API is slow, queue the work async and return immediately. See the Queueable pattern from Chapter 3. Synchronous slowness is the silent killer of agent UX.
9. Permission set group changes silently break agents
Someone modifies a permission set group the agent user belongs to. A permission gets removed. The agent loses access to a field. Nobody notices until a customer asks about that field and the agent answers "I don't know." Use dedicated permission sets for the agent user, not groups shared with humans. Add a daily smoke test that exercises critical paths. Treat the agent's permissions like production code: versioned, reviewed, hands off.
10. Sandbox refresh wipes agent activation
After a production-source refresh, the metadata is there but the agent isn't activated. Everything looks fine in Setup. Nothing works for users. Build a post-refresh script that re-authorizes Named Credentials, re-activates agents, verifies connected app consumer keys, and runs a smoke test. Schedule it. Trust no manual checklist with this job.
11. FOR UPDATE can't be combined with ORDER BY
Salesforce explicitly forbids combining the two. If you're using FOR UPDATE for record locking (which you should, in any booking or inventory scenario), make sure your SOQL doesn't have an ORDER BY clause. The error message is unhelpful. The fix is removing one line. Don't waste twenty minutes on this one. Just remove the ORDER BY.
12. Conversation storage fills up faster than you think
Every agent conversation persists every message, tool call, and model response. A heavy agent generates substantial data, and default retention is forever. Yes, forever. Set a retention policy in writing. Implement a scheduled job that archives old conversations. Do this before storage costs become a Slack message from finance.
For PHI/PII conversations, the retention policy isn't a nice-to-have. It's mandated by your compliance regime, and your auditor will ask. Have an answer ready.
As of API 65, conversation logs land in ConversationDefinitionEventLog. API 65+ adds UiAgentInteractionEventLog for UI-side events. Field availability and retention windows depend on your Event Monitoring license. Check your specific org's setup before scheduling delete jobs. Wrong assumption here = compliance violation.
Production teaches the rules nobody documented.
The lesson always arrives. The only question
is whether you read this chapter before it does.
The Pre-Flight Checklist.
One page. Print it. Stick it on your monitor. Before you activate any agent in production, run through this list. Every checked box is a postmortem you didn't have to write. The boxes you can't check are the conversations you need to have today, not on launch day. If every box checks, you've done what's reasonable. The rest is production telling you what couldn't have been predicted.
Topic / Subagent Design
Salesforce renamed topics to subagents in April 2026. The metadata type is still GenAiPlugin. The mental model is unchanged.
- Naming convention defined:
{Domain}_{Capability}or equivalent - No subagent exceeds 12 actions
- Every subagent has both
descriptionANDscope - Cross-subagent routing is explicit in scope statements
- Test set covers utterances that should NOT match each subagent
Invocable Design
- Every invocable uses a wrapper input class (never raw primitives)
- Every
@InvocableVariablehas a description - All SOQL uses
WITH USER_MODE - All DML uses
as userorAccessLevel.USER_MODE - No invocable returns a raw sObject
- Result lists capped at 5 to 25, with overflow message
- All invocables return errors as fields, never throw uncaught
- Async work uses Queueable plus status-check action (not Continuations)
isConfirmationRequired=trueon every write action- HTTP callouts have explicit
setTimeoutvalues
Testing
- Apex unit tests cover happy path, error paths, and bulk for every invocable
- Test data created in
@TestSetup, never assumed from org - Action selection regression suite exists in Testing Center
- Negative tests included (what the agent should NOT do)
- Regression suite runs after every Salesforce release
- Test utterances refreshed quarterly from real conversation logs
Permissions and Security
- System integrations use Salesforce Integration license users
- Agent runtime user has correct permission sets stacked
- Connected app or External Client App uses minimum scopes (
api,refresh_token) - No real human user has
Waive Multi-Factor Authentication for Exempt Users - FLS validated by running tests as the agent user, not admin
- Sensitive fields excluded from agent context or redacted
- Connected user password set to never expire (with documented justification)
- Agentforce Security Brief written and signed off
Deployment
sourceApiVersionat least 64; 65 if usingAiAuthoringBundle- SFDX project structure correct (
genAiFunctionsas folders) - CI pipeline validates on PR, deploys to UAT on merge, prod on release branch
- Deploy order: Apex → permission sets → Bot/BotVersion → Function → Plugin → Planner
- Quick-deploy uses validated job Id (no direct prod deploys)
- BotVersion activated post-deploy
- Post-sandbox-refresh script exists and runs after every refresh
Operations
- Daily smoke test exercises critical agent paths
- Storage and conversation retention policy defined and implemented
- Alert configured on agent user deactivation or password expiration
- Connected user effective permissions audited monthly
- Rollback playbook documented (deactivate path, don't revert metadata)
- Runbook documents the connected user, what it does, who owns it
- Previous BotVersion identified and activation script ready
If every box checks, you've shipped a production-ready Agentforce deployment. The rest is the platform telling you what nobody could have predicted.
That's the job. Write the menu. Watch the brain pick from it. Fix what breaks. Sleep through the night.
Seven laws. Eight chapters. One production-ready agent at a time.
Now go ship the next one.
Built by Quinn Studio, the team that ships Agentforce on subscription. 48-hour delivery, pause anytime. Three of five founding spots still open at the locked rate.