Friday, July 31, 2026

How to Use DML When Using Lightning Web Component for Quick Action

How to Use DML When Using Lightning Web Component for Quick Action

Lightning Web Components (LWC) aren't just for record pages and app screens — they can also power Quick Actions, giving users a fast, focused way to create or update records without leaving the page they're on. But since LWC itself can't talk to the database directly, any DML (Data Manipulation Language) operation — insert, update, delete, or upsert — has to go through Apex.

This post walks through exactly how that wiring works: building an LWC-based Quick Action, calling an Apex method to perform DML, and handling the response so the action closes cleanly and refreshes the page.

Why Use LWC for a Quick Action?

Salesforce Quick Actions can be built a few different ways — standard record-creation actions, Flow-based actions, or a custom Lightning Web Component quick action. The LWC route is worth reaching for when you need:

  • Custom validation logic before saving
  • A tailored layout that doesn't match the standard object layout
  • Multiple related records created or updated in a single click
  • Conditional UI (showing/hiding fields based on other values) that a standard action can't easily do

Step 1: Build the LWC Component

Any LWC used as a Quick Action needs to implement lightning__RecordAction in its target configuration, and its JavaScript class needs access to the current record's ID (passed in automatically as recordId).

HTML Template

<template>
    <lightning-card title="Log a Follow-Up Task">
        <div class="slds-p-around_medium">
            <lightning-input
                label="Subject"
                value={subject}
                onchange={handleSubjectChange}>
            </lightning-input>

            <lightning-input
                type="date"
                label="Due Date"
                value={dueDate}
                onchange={handleDueDateChange}>
            </lightning-input>

            <lightning-button
                variant="brand"
                label="Save"
                onclick={handleSave}
                class="slds-m-top_medium">
            </lightning-button>
        </div>
    </lightning-card>
</template>

JavaScript Controller

import { LightningElement, api } from 'lwc';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { CloseActionScreenEvent } from 'lightning/actions';
import { updateRecord } from 'lightning/uiRecordApi';
import createFollowUpTask from '@salesforce/apex/QuickActionController.createFollowUpTask';

export default class LogFollowUpTask extends LightningElement {
    @api recordId; // Automatically populated with the current record's Id

    subject = '';
    dueDate = '';

    handleSubjectChange(event) {
        this.subject = event.target.value;
    }

    handleDueDateChange(event) {
        this.dueDate = event.target.value;
    }

    handleSave() {
        createFollowUpTask({
            whatId: this.recordId,
            subject: this.subject,
            dueDate: this.dueDate
        })
        .then(() => {
            this.showToast('Success', 'Follow-up task created.', 'success');
            this.dispatchEvent(new CloseActionScreenEvent());
        })
        .catch((error) => {
            this.showToast('Error creating task', error.body.message, 'error');
        });
    }

    showToast(title, message, variant) {
        this.dispatchEvent(new ShowToastEvent({ title, message, variant }));
    }
}

A few details worth calling out:

  • @api recordId is what makes this component aware of which record launched the action — Salesforce populates it automatically when the component is used as a Quick Action.
  • CloseActionScreenEvent is what closes the Quick Action panel after a successful save. Without dispatching it, the modal stays open even after the DML succeeds.
  • ShowToastEvent gives the user immediate feedback, whether the save succeeded or failed.

Step 2: Write the Apex Controller That Performs the DML

The actual insert/update logic lives in Apex, exposed to the LWC through an @AuraEnabled method:

public with sharing class QuickActionController {

    @AuraEnabled
    public static void createFollowUpTask(Id whatId, String subject, Date dueDate) {
        try {
            Task t = new Task();
            t.WhatId = whatId;
            t.Subject = subject;
            t.ActivityDate = dueDate;
            t.Status = 'Not Started';

            insert t;

        } catch (DmlException e) {
            throw new AuraHandledException('Unable to save task: ' + e.getMessage());
        }
    }
}

A few best practices baked into this pattern:

  • with sharing ensures the DML respects the running user's record-level access, rather than silently bypassing it.
  • Wrapping the DML in a try/catch and re-throwing as AuraHandledException is essential — a plain DmlException won't surface a readable message to the LWC's .catch() block. AuraHandledException is specifically designed to safely pass error details back to client-side JavaScript.
  • Keep the method focused on a single responsibility. If you need to create multiple related records, build the full list of sObjects in Apex and perform one bulk DML call rather than several separate inserts.

Step 3: Handling Bulk or Related DML Safely

If your Quick Action needs to create or update more than one record — say, a Task and a related Note — always batch the DML into a single call per object type rather than looping inserts one at a time:

@AuraEnabled
public static void createTaskWithNote(Id whatId, String subject, Date dueDate, String noteBody) {
    try {
        Task t = new Task(
            WhatId = whatId,
            Subject = subject,
            ActivityDate = dueDate,
            Status = 'Not Started'
        );
        insert t;

        ContentNote note = new ContentNote();
        note.Title = subject + ' - Notes';
        note.Content = Blob.valueOf(noteBody);
        insert note;

        ContentDocumentLink link = new ContentDocumentLink();
        link.ContentDocumentId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :note.Id].ContentDocumentId;
        link.LinkedEntityId = t.Id;
        link.ShareType = 'V';
        insert link;

    } catch (Exception e) {
        throw new AuraHandledException('Unable to save task and note: ' + e.getMessage());
    }
}

This keeps all the DML server-side in one transaction, so either everything saves together or nothing does — avoiding a half-completed state if one insert fails partway through.

Step 4: Refreshing the UI After a Quick Action

Because the DML happens in Apex and not through Lightning Data Service, the record page won't automatically reflect the change once the action closes. Two common approaches:

Option A: Refresh the view using RefreshEvent

import { RefreshEvent } from 'lightning/refresh';
// ...
this.dispatchEvent(new RefreshEvent());

Option B: Use getRecordNotifyChange from Lightning Data Service

import { getRecordNotifyChange } from 'lightning/uiRecordApi';
// ...
getRecordNotifyChange([{ recordId: this.recordId }]);

Either approach tells the platform's caching layer that the underlying record has changed, so any related lists, related record fields, or other components on the page pick up the fresh data without a full page reload.

Step 5: Configuring the Quick Action in Setup

Once the component is deployed, expose it as a Quick Action from Setup:

  1. Go to Object Manager → select your object → Buttons, Links, and Actions.
  2. Click New Action.
  3. Set Action Type to Lightning Web Component.
  4. Choose your LWC from the Lightning Web Component picklist (only components with lightning__RecordAction in their js-meta.xml will appear here).
  5. Give the action a Label and Name, then save.
  6. Add the new Quick Action to the object's page layout so it appears as a button.

Here's the required js-meta.xml configuration for the component to be selectable as a Quick Action:

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>60.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>Log Follow-Up Task</masterLabel>
    <targets>
        <target>lightning__RecordAction</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__RecordAction">
            <actionType>ScreenAction</actionType>
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

Common Pitfalls to Avoid

  • Forgetting CloseActionScreenEvent. Without it, users are left staring at a modal that appears to do nothing after clicking Save, even though the record saved successfully.
  • Not wrapping DML in a try/catch. Uncaught Apex exceptions return a generic, unhelpful error to the LWC — always catch and re-throw as AuraHandledException with a clear message.
  • Looping DML statements inside Apex. Even in a Quick Action context, build lists and insert/update them once outside of loops to respect governor limits and avoid hitting DML row limits on records with many related children.
  • Skipping the refresh step. Users expect the page to reflect their change immediately — dispatching RefreshEvent or calling getRecordNotifyChange closes that gap.
  • Missing with sharing. Leaving the controller class as without sharing (the Apex default) means the DML runs with elevated access regardless of the user's actual permissions — a subtle but serious security gap.

Key Takeaways

  • LWC Quick Actions can't perform DML directly — all inserts, updates, or deletes must go through an @AuraEnabled Apex method.
  • Use with sharing on your Apex controller and wrap DML in try/catch, re-throwing as AuraHandledException so errors surface cleanly in the LWC.
  • Dispatch CloseActionScreenEvent after a successful save so the action panel closes automatically.
  • Use RefreshEvent or getRecordNotifyChange to make sure the record page reflects the change immediately.
  • For multi-object DML, batch everything into one Apex transaction rather than multiple round trips, so the whole operation succeeds or fails together.

With this pattern, you get the flexibility of a fully custom UI for your Quick Action, backed by clean, governor-limit-friendly Apex DML — giving users a fast, reliable way to create or update records without ever leaving the record page.

Using GraphQL to Power Multi-Framework Apps in Salesforce with React

 

Using GraphQL to Power Multi-Framework Apps in Salesforce with React

For most of Salesforce's history, front-end developers faced a hard choice: build with Lightning Web Components (LWC) and get full platform integration, or reach for React and lose native access to Salesforce's data, security, and governance model. That trade-off has now changed. With Salesforce Multi-Framework — announced at TrailblazerDX 2026 and currently in open beta — React apps can run natively on the Salesforce platform, sitting alongside your existing LWC components rather than replacing them. And the piece tying it all together on the data side is GraphQL.

This post looks at how GraphQL fits into a React-on-Salesforce architecture, why it matters, where this pattern is headed, and a concrete use case you can build today.

A Quick Recap: What Is Salesforce Multi-Framework?

Salesforce Multi-Framework is a framework-agnostic runtime on the Agentforce 360 Platform that lets developers build native Salesforce apps using React (with more frameworks planned for the future). Instead of compiling a React app into a static resource and hacking it into a Visualforce page or lightning:container — the old workaround — Multi-Framework deploys your React app as a first-class metadata type called a UI Bundle. It runs on Salesforce's core application servers, side by side with LWC, and can be surfaced through the App Launcher.

Crucially, this isn't a replacement for LWC. Your existing Lightning Web Components keep working exactly as before. Multi-Framework is additive — a second front-end option for teams that want the broader React ecosystem (component libraries, routing, state management, tooling) without giving up Salesforce's authentication, security, and governance.

Where GraphQL Fits In



The real question with any new front-end framework on Salesforce is: how does it talk to data? This is where GraphQL becomes the backbone of the architecture.

React apps built with Multi-Framework use a package called @salesforce/sdk-data — often referred to as the Data SDK — to interact with Salesforce data. Instead of the reactive @wire adapters LWC developers are used to, React components use standard patterns like useEffect and useState, but under the hood, the SDK runs GraphQL queries and mutations against the UI API.

A few things make this pairing particularly effective:

  • One unified data interface. The same Data SDK works whether your app is an internal tool opened through the App Launcher or a customer-facing portal on an Experience Cloud site. The SDK is runtime-aware, so you write the same GraphQL-based data-access code regardless of where the app is deployed.
  • Exact field selection, in one request. Just like GraphQL's core value proposition on any platform, a React component can request precisely the fields and nested relationships it needs — an account, its contacts, and their open cases, for example — in a single round trip, instead of stitching together multiple REST or Apex calls.
  • Governance built in, not bolted on. Because the React runtime is embedded inside the platform layer, every GraphQL query and mutation is still subject to Salesforce's sharing rules, field-level security, and permissions — the same protections your LWC components already rely on.
  • Chained operations in a single request. Recent platform updates allow GraphQL mutations to reference fields returned by an earlier operation in the same call (not just a record ID), so you can create linked records — a parent and its children, for instance — in one round trip instead of several.

In short: React gives you the frontend flexibility, and GraphQL gives you a single, permission-aware, precisely-shaped data layer underneath it — without needing a custom middleware or a separate backend-for-frontend service.

Advantages of This Approach

1. No more framework-vs-platform trade-off. Teams that want React's ecosystem — its component libraries, its hiring pool, its tooling — no longer have to give up native Salesforce data access, authentication, or governance to get it.

2. Fewer round trips, faster apps. Because GraphQL lets a component ask for exactly the data it needs across multiple related objects in one call, React apps built this way avoid the classic over-fetching and under-fetching problems of REST, translating into snappier UIs, especially on data-heavy dashboards.

3. Consistent security model. Since GraphQL queries run against the UI API, the same field-level security and sharing rules that protect your Lightning pages automatically protect your React app's data — there's no separate authorization layer to build or maintain.

4. Reusable skills and code across runtimes. Because the Data SDK is runtime-aware, the same GraphQL-based data access code can power an internal employee tool and a branded customer portal on Experience Cloud, cutting down on duplicated data-fetching logic.

5. Bring your own tooling. Standard React tooling — Vite, Vitest, npm packages, React DevTools, hot module reload — all work as expected, so teams don't have to relearn a Salesforce-specific build process just to get productive.

The Futuristic Demand: Why This Matters Going Forward

A few trends suggest this pattern is only going to become more central to Salesforce development:

  • The framework-choice pressure is real. For years, teams building complex, highly customized front ends had to either compromise on LWC's constraints or host a separate React app off-platform (often on Heroku or similar), wiring up their own OAuth flows and losing native platform benefits. Multi-Framework directly removes that trade-off, and as it matures toward general availability, expect more teams to standardize on it for net-new, complex UI work.
  • More frameworks are coming. React is supported today, with Angular and Vue expected in future releases. As the runtime becomes framework-agnostic in practice (not just in name), GraphQL — not any one framework's data-fetching convention — becomes the common language every framework uses to talk to Salesforce data.
  • AI-assisted app generation is layering on top. Tools like Agentforce Vibes can already generate React code, GraphQL queries, and the associated Salesforce metadata from a natural-language description of the component you want. As this tooling matures, GraphQL's declarative, schema-driven nature makes it a natural target for AI code generation — you describe the data you want, and the tool writes the precise query.
  • Micro-frontend embedding is on the roadmap. Beyond standalone React apps, Salesforce has also signaled plans for embedding React components directly into Lightning pages as micro-frontends, which would let GraphQL-powered React widgets live inside existing Lightning Experience pages alongside LWC components.

Put together, this points toward an ecosystem where GraphQL becomes the default, framework-agnostic way to move data in and out of Salesforce — regardless of which UI framework a particular team or component happens to use.

A Practical Use Case: A Customer-Facing Order Tracking Portal

Consider a company that sells industrial equipment and wants a branded, self-service portal where customers can track their orders, view shipment status, and see related service cases — all without contacting support.

Why this fits the pattern well:

  • The portal needs a highly customized, brand-specific UI — exactly the kind of experience React's ecosystem (design systems, animation libraries, custom layouts) is suited for, beyond what out-of-the-box LWC base components easily support.
  • The data itself is relational: an Account, its Orders, each order's shipment status, and any related Cases — precisely the kind of nested, multi-object data a single GraphQL query can retrieve in one request instead of several sequential calls.
  • It needs to be customer-facing, deployed through Experience Cloud, while still respecting the same sharing rules and field-level security that protect internal data — which GraphQL against the UI API handles automatically.
  • As the business grows, the same Data SDK and GraphQL queries that power the customer portal could be reused, with different permissions, for an internal support-agent view of the same data — avoiding duplicate data-access code between the two experiences.

A rough shape of the GraphQL query powering the order-tracking screen might look like this:

query GetCustomerOrders($accountId: ID!) {
  uiapi {
    query {
      Account(where: { Id: { eq: $accountId } }) {
        edges {
          node {
            Name { value }
            Orders__r {
              edges {
                node {
                  OrderNumber__c { value }
                  Status__c { value }
                  ShipDate__c { value }
                  Cases__r {
                    edges {
                      node {
                        CaseNumber { value }
                        Status { value }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

One request returns the account, every order tied to it, and any related cases per order — everything the portal's UI needs to render a complete order-tracking view, wired into a React component through the Data SDK.

Wrapping Up

Salesforce Multi-Framework closes a long-standing gap for developers who wanted React's flexibility without sacrificing native Salesforce integration. GraphQL is what makes that pairing work in practice — giving React components a single, precise, permission-aware way to read and write Salesforce data, whether the app lives behind the App Launcher or out on a public-facing Experience Cloud site. As more frameworks join Multi-Framework and AI-assisted tools generate GraphQL queries automatically, this combination looks set to become a standard part of the Salesforce development toolkit rather than a niche pattern — well worth exploring now while it's still in beta.

What Is a GraphQL Query

 

Understanding GraphQL Queries: A Complete Guide

If you've worked with REST APIs, you know the drill: you hit an endpoint, and you get back whatever shape of data that endpoint decides to send you — sometimes too much, sometimes too little, often requiring several round trips to assemble what you actually need. GraphQL was built to solve exactly that problem. At its core is the query — the primary way clients ask a GraphQL API for data.

This post breaks down what GraphQL queries are, how they work, and the features that make them so powerful for building efficient, flexible APIs.

What Is a GraphQL Query?

A GraphQL query is a read operation that lets a client specify exactly what data it wants, in exactly the shape it wants it, from a single endpoint. Unlike REST, where the server defines fixed response structures for each URL, GraphQL flips that responsibility to the client.

Here's the simplest possible example:

query {
  user(id: "1") {
    name
    email
  }
}

The server responds with only what was asked for:

{
  "data": {
    "user": {
      "name": "Jane Doe",
      "email": "jane@example.com"
    }
  }
}

No extra fields, no under-fetching, no separate call needed for related data.

Anatomy of a Query

1. Fields

A query is built from fields, which map to properties on your data types. You can request as many or as few fields as you need:

query {
  user(id: "1") {
    name
    email
    createdAt
  }
}

2. Arguments

Fields can accept arguments, letting you filter, paginate, or parameterize a request — something REST typically handles with query strings, but GraphQL bakes directly into the schema:

query {
  posts(limit: 5, status: PUBLISHED) {
    title
    publishedAt
  }
}

3. Nested Fields (Relationships)

This is where GraphQL really shines. Because fields can return objects, you can traverse relationships in a single request — something that would take multiple REST calls:

query {
  user(id: "1") {
    name
    posts {
      title
      comments {
        text
        author {
          name
        }
      }
    }
  }
}

One request, one round trip, and you get the user, their posts, each post's comments, and each comment's author — all nested exactly the way your UI needs it.

4. Aliases

Sometimes you need the same field twice with different arguments — for example, fetching two users in one query. Since both would normally be called user, GraphQL lets you rename them with aliases:

query {
  first: user(id: "1") {
    name
  }
  second: user(id: "2") {
    name
  }
}

The response keys match the aliases (first and second) instead of colliding on user.

5. Variables

Hardcoding values directly into a query string works for quick tests, but real applications need dynamic input. Variables let you parameterize a query cleanly, similar to how you'd use parameters in a SQL prepared statement:

query GetUser($userId: ID!) {
  user(id: $userId) {
    name
    email
  }
}

The variables are passed alongside the query, typically as JSON:

{
  "userId": "1"
}

This keeps your query text static and cacheable while still supporting dynamic values — and it separates "what data do I want" from "with what specific input," which is much cleaner for client code.

6. Fragments

When multiple queries need to request the same set of fields, repeating them everywhere gets messy. Fragments let you define a reusable field set once:

fragment UserFields on User {
  id
  name
  email
}

query {
  user(id: "1") {
    ...UserFields
  }
}

This is especially useful in larger applications where the same "shape" of data (say, a user summary card) is needed across many different screens or components.

7. Directives

Directives let you conditionally include or skip fields at runtime, based on variables — without writing two separate queries:

query GetUser($withEmail: Boolean!) {
  user(id: "1") {
    name
    email @include(if: $withEmail)
  }
}

The built-in directives are @include(if: Boolean) and @skip(if: Boolean), and they're evaluated per-request based on the variables you send.

Queries vs. Mutations vs. Subscriptions

It's worth placing queries in context with GraphQL's other two operation types:

  • Query — read-only, fetches data, has no side effects.
  • Mutation — used to create, update, or delete data (a GraphQL "write" operation).
  • Subscription — opens a persistent connection so the client receives real-time updates when data changes.

Queries are the most commonly used of the three, since most application screens are primarily about displaying data.

Why Queries Solve Common REST Problems

Over-fetching — In REST, an endpoint like /users/1 might return dozens of fields even if your screen only needs the name and email. GraphQL queries request only the fields you specify, nothing more.

Under-fetching — If a REST endpoint doesn't return related data (say, a user's recent orders), you often need a second call to another endpoint. GraphQL lets you request nested relationships in the same query.

Multiple round trips — Combining over-fetching and under-fetching issues, complex UI screens in REST often require several sequential or parallel API calls. A single GraphQL query can gather everything needed in one request.

API versioning pressure — Since clients declare exactly which fields they want, adding new fields to a schema doesn't break existing queries, reducing the pressure to constantly version your API.

A Practical Example

Imagine a product page that needs the product details, its reviews, and related products — normally three separate REST calls. In GraphQL, it's one query:

query ProductPage($productId: ID!) {
  product(id: $productId) {
    name
    price
    description
    reviews {
      rating
      comment
      author {
        name
      }
    }
    relatedProducts {
      name
      price
    }
  }
}

This single request gives the front end everything it needs to render the entire page.

Best Practices for Writing GraphQL Queries

  • Request only what you need. The biggest advantage of GraphQL is precision — don't fall into old REST habits of fetching entire objects "just in case."
  • Use variables, not string interpolation. Passing values as variables avoids injection risks and keeps queries cacheable.
  • Name your queries. Instead of anonymous query { ... } blocks, name them (query GetUserProfile { ... }) — this makes debugging, logging, and client-side caching much easier.
  • Use fragments for shared field sets to avoid duplicating the same field lists across multiple queries.
  • Watch your query depth. Deeply nested queries (comments on posts on users on posts...) can create performance problems on the server; many GraphQL servers enforce query depth or complexity limits for this reason.

Wrapping Up

GraphQL queries give clients precise control over the data they fetch — solving the over-fetching, under-fetching, and multiple-round-trip problems that are common with REST APIs. By combining fields, arguments, nested relationships, variables, fragments, and directives, you can express almost any data requirement in a single, readable request. Once you're comfortable writing queries, the natural next steps are learning mutations for writing data and subscriptions for real-time updates.

How to Convert CSV Response in to Excel sheet with Download by using LWC

How to Convert CSV Response into an Excel Sheet with Download by Using LWC

Reporting screens often need two things at once: a readable table on screen, and a downloadable file the user can open in Excel. Rather than building two separate data pipelines, you can fetch a single CSV string from Apex, render it as a table in your Lightning Web Component, and let the same string be downloaded as a .csv file with one click.

This post walks through a working pattern for exactly that — fetching report data, parsing raw CSV text into a JavaScript object array, displaying it in the UI, and exporting it back out as a downloadable spreadsheet.

Step 1: Fetch the Report Data from Apex

The component calls an imperative Apex method, passing along whatever filters the user has selected (in this case, a searchWrapper object holding search parameters):

getReportData() {
    this.isLoading = true;

    getReportData({
        searchParams: this.searchWrapper
    })
    .then(result => {
        this.csvdata = result;
        this.formatReportData(result);
        this.isLoading = false;
    })
    .catch(error => {
        console.error('getReportData error', error);
        this.isLoading = false;
    });
}

The Apex method is expected to return a raw CSV string (header row + data rows). That string is stored as-is in this.csvdata — this is important, because it's what gets handed off later for the file download, untouched by any formatting done for display purposes.

Step 2: Turn the CSV String into an Array of Objects

To show the data in a table (or any other UI component), the raw CSV text needs to become structured JavaScript objects. The formatReportData method handles this:

formatReportData(info) {
    var resultInfo = info.trim().split(/\r?\n|\r/);
    var headerColumns = resultInfo[0].replaceAll(' ', '_').trim().split(',');
    var dataObj = [];

    for (let index = 0; index < resultInfo.length; ++index) {
        const element = resultInfo[index];
        if (index !== 0) {
            var rowData = this.CSVtoArray(element);
            var obj = {};
            for (let colIndex = 0; colIndex < rowData.length; ++colIndex) {
                obj[headerColumns[colIndex]] = rowData[colIndex];
            }
            dataObj.push(obj);
        }
    }

    this.reportData = dataObj;
    this.showDataResult = dataObj.length > 0;
}

A few things worth noting here:

  • Line splitting is resilient. split(/\r?\n|\r/) handles CSV files coming from Windows (\r\n), Mac (\r), or Unix (\n) line endings, which matters since exported reports can originate from different systems.
  • Header names are sanitized. Spaces in column headers are replaced with underscores, making them safe to use as JavaScript object keys.
  • The first row is skipped when building data rows, since it only contains column headers.
  • showDataResult acts as a simple flag to conditionally show or hide the results table in the template, depending on whether any rows came back.

Step 3: Parse Each Row Safely with a Regex-Based CSV Splitter

A simple .split(',') breaks as soon as a field contains a comma inside quotes (for example, "Doe, John"). To avoid that, the component uses a small, well-tested regex-based parser:

CSVtoArray(text) {
    var re_valid = /^\s*(?:'[^'\\]*(?:\\[\S\s][^'\\]*)*'|"[^"\\]*(?:\\[\S\s][^"\\]*)*"|[^,'"\s\\]*(?:\s+[^,'"\s\\]+)*)\s*(?:,\s*(?:'[^'\\]*(?:\\[\S\s][^'\\]*)*'|"[^"\\]*(?:\\[\S\s][^"\\]*)*"|[^,'"\s\\]*(?:\s+[^,'"\s\\]+)*)\s*)*$/;
    var re_value = /(?!\s*$)\s*(?:'([^'\\]*(?:\\[\S\s][^'\\]*)*)'|"([^"\\]*(?:\\[\S\s][^"\\]*)*)"|([^,'"\s\\]*(?:\s+[^,'"\s\\]+)*))\s*(?:,|$)/g;

    if (!re_valid.test(text)) return null;

    var a = [];
    text.replace(re_value, function(m0, m1, m2, m3) {
        if (m1 !== undefined) a.push(m1.replace(/\\'/g, "'"));
        else if (m2 !== undefined) a.push(m2.replace(/\\"/g, '"'));
        else if (m3 !== undefined) a.push(m3);
        return '';
    });

    if (/,\s*$/.test(text)) a.push('');
    return a;
}

This function:

  1. First validates the line against re_valid to confirm it's a well-formed CSV row — returning null immediately if not.
  2. Then walks through the line with re_value, correctly extracting values that are single-quoted, double-quoted, or unquoted.
  3. Unescapes any backslash-escaped quotes inside quoted values.
  4. Handles the edge case of a trailing empty value (a line ending in a comma).

Because this parser respects quotes, it can safely handle addresses, names, or notes fields that contain embedded commas — something a naive split would corrupt.

Step 4: Let the User Download the Same Data as a CSV File

Since the raw CSV string was preserved in this.csvdata, generating a downloadable file just means creating a temporary link element and triggering a click on it:

getDatasheet() {
    let csv = this.csvdata;
    if (csv == null) { return; }

    var hiddenElement = document.createElement('a');
    hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(csv);
    hiddenElement.target = '_self';
    hiddenElement.download = 'Export.csv';
    document.body.appendChild(hiddenElement);
    hiddenElement.click();
}

Because the file is saved with a .csv extension and a text/csv MIME type, double-clicking it on most machines opens it directly in Excel or Google Sheets — no server round trip or extra Apex logic required for the export itself.

Step 5: Wiring Up Filter Selections

The component also supports multi-select filters for material and plant, which get converted into comma-separated strings before being sent to Apex:

handleMaterial(event) {
    let selectedMaterial = event.detail
        .map(x => x.selected === true ? x.value : null)
        .filter(v => v !== null);
    this.searchWrapper.material = selectedMaterial.toString();
}

handlePlant(event) {
    let selectedPlant = event.detail
        .map(x => x.selected === true ? x.value : null)
        .filter(v => v !== null);
    this.searchWrapper.plant = selectedPlant.toString();
}

These selections feed directly into searchWrapper, which is passed to getReportData() on the next fetch.

Key Takeaways

  • Keep the raw CSV string intact for downloading, and only transform a copy of it for on-screen display — this avoids double work and keeps the exported file identical to the source data.
  • Use a regex-based CSV parser instead of a plain split(',') whenever fields might contain commas or quoted values.
  • Normalize line endings with split(/\r?\n|\r/) so the parser works regardless of which OS generated the file.
  • Generating a downloadable file client-side with a data:text/csv URI avoids extra server calls — the browser handles the file creation.
  • Toggle a boolean flag (like showDataResult) based on whether parsed data exists, so your template can cleanly show empty states versus populated tables.

With this approach, a single Apex call and a lightweight JavaScript parser give you both an interactive report table and a one-click Excel-ready export — all inside one LWC.

How to Read CSV file and Insert record using Apex/LWC in Salesforce

How to Read CSV File and Insert Records Using Apex/LWC in Salesforce

Uploading data through CSV files is one of the most common requirements in Salesforce projects. Instead of relying on Data Loader or manual imports, you can build a simple Lightning Web Component (LWC) that lets users upload a CSV file directly from the UI, then have an Apex controller parse that file and create or update records automatically.

In this post, we'll walk through a working example that:

  • Lets a user upload a .csv file via lightning-file-upload
  • Reads the uploaded file's content in Apex
  • Parses CSV rows — including values that contain commas inside quotes
  • Uses the parsed data to create, update, and upsert related Salesforce records

Step 1: Build the LWC Upload Component

The front end is intentionally lightweight. It uses the standard lightning-file-upload base component wrapped inside a lightning-card, restricted to .csv files only.

HTML Template

<template>
    <lightning-card title="CSV Uploader">
        <div class="slds-box slds-m-around_medium">
            <lightning-file-upload
                accept={acceptedFormats}
                label="Attach CSV File"
                onuploadfinished={uploadFileHandler}>
            </lightning-file-upload>
        </div>
    </lightning-card>
</template>

JavaScript Controller

import { LightningElement, track } from 'lwc';
import loadCSVData from '@salesforce/apex/CSVController.loadCSVData';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { showToast, handleArraySort } from 'c/cOP_LWC_Utils';

export default class FileUploadExcel extends LightningElement {

    get acceptedFormats() {
        return ['.csv'];
    }

    @track contentDocumentId;
    @track recordCount;

    uploadFileHandler(event) {
        const uploadedFiles = event.detail.files;
        this.contentDocumentId = uploadedFiles[0].documentId;

        loadCSVData({ contentDocumentId: this.contentDocumentId })
            .then((result) => {
                this.recordCount = result;
            })
            .catch((error) => {
                // handle error, e.g. show a toast
            });
    }
}

Once the file finishes uploading, Salesforce automatically stores it as a ContentVersion record and returns a documentId. That ID is passed straight to an Apex method, which does all the heavy lifting.

Step 2: Read the File Content in Apex

The uploaded file is stored as a Blob on the ContentVersion.VersionData field. Since Apex doesn't have a built-in "read as string with encoding" helper for every use case, a small utility method converts the blob into a readable string using a specified character set (here, ISO-8859-1, which is a safe default for most CSV exports):

public static String blobToString(Blob input, String inCharset) {
    String hex = EncodingUtil.convertToHex(input);
    System.assertEquals(0, hex.length() & 1);
    final Integer bytesCount = hex.length() >> 1;
    String[] bytes = new String[bytesCount];
    for (Integer i = 0; i < bytesCount; ++i) {
        bytes[i] = hex.mid(i << 1, 2);
    }
    return EncodingUtil.urlDecode('%' + String.join(bytes, '%'), inCharset);
}

Once you have the string, splitting it into rows is as simple as:

string[] csvFileLines = data.split('\n');

Step 3: Handle Commas Inside Quoted Values

A naive split(',') breaks down the moment a CSV value itself contains a comma — for example, an address field like "Springfield, IL". To handle this correctly, a helper method temporarily replaces quotes and internal commas with placeholder tokens (:quotes: and :comma:), performs the split, and then restores the original characters:

public static string csvdata(string csinfo) {
    String csvLine = csinfo;
    Integer startIndex;
    Integer endIndex;

    while (csvLine.indexOf('"') > -1) {
        if (startIndex == null) {
            startIndex = csvLine.indexOf('"');
            csvLine = csvLine.substring(0, startIndex) + ':quotes:' + csvLine.substring(startIndex + 1);
        } else if (endIndex == null) {
            endIndex = csvLine.indexOf('"');
            csvLine = csvLine.substring(0, endIndex) + ':quotes:' + csvLine.substring(endIndex + 1);
        }

        if (startIndex != null && endIndex != null) {
            String sub = csvLine.substring(startIndex, endIndex).replaceAll(',', ':comma:');
            csvLine = csvLine.substring(0, startIndex) + sub + csvLine.substring(endIndex);
            startIndex = null;
            endIndex = null;
        }
    }
    return csvLine;
}

Each field is later restored with .replaceAll(':quotes:', '').replaceAll(':comma:', ',') before it's assigned to a record field.

Step 4: Map CSV Rows to Salesforce Records

With clean, split rows in hand, the controller builds sets and maps to minimize SOQL queries — a best practice to avoid governor limit issues when processing many rows:

set<string> supplierid = new set<string>();
map<Decimal, list<string>> mapincvdata = new map<Decimal, list<string>>();

for (Integer i = 1; i < csvFileLines.size(); i++) {
    string row = CSVController.csvdata(csvFileLines[i]);
    string[] csvRecordData = row.split(',');
    supplierid.add(csvRecordData[0]);
    mapincvdata.put(Decimal.valueOf(csvRecordData[0]), csvRecordData);
}

A single query then retrieves all matching Account records at once:

map<string, id> acMap = new map<string, id>();
for (Account acc : [SELECT Id, SupplierID__c FROM Account WHERE SupplierID__c IN :supplierid]) {
    acMap.put(acc.SupplierID__c, acc.Id);
}

Step 5: Create, Update, and Upsert Records

From here, the controller builds three separate collections depending on the business need:

  • Program_Association__c records are created fresh for each row, using an external key (SupplierNProgramId__c) so the same upsert call can safely run multiple times without creating duplicates.
  • InfSupplierDetail__c records are updated with new association start/end dates pulled from the CSV.
  • InfRequestInfo__c records are updated with the relevant program ID, using a set to avoid processing the same request twice.
if (!updateSupplier.isEmpty()) {
    update updateSupplier;
}
if (!updateRequestProgramid.isEmpty()) {
    update updateRequestProgramid;
}
if (!lstProgramAssociationsToUpsert.isEmpty()) {
    upsert lstProgramAssociationsToUpsert SupplierNProgramId__c;
}

Finally, the method returns a short summary string so the LWC can display feedback to the user:

string res = 'Total Record: ' + (csvFileLines.size() - 1) +
             '---Supplier Updated:' + updateSupplier.size() +
             '--Program Association Created:' + lstProgramAssociationsToUpsert.size() +
             '---Request Updated: ' + updateRequestProgramid.size();
return res;

A Simpler Visualforce Alternative

If you don't need the modern LWC experience, the same idea can be done with a classic Visualforce controller. This trimmed-down version reads an uploaded file and creates Account records directly from each CSV column:

public Pagereference ReadFile() {
    try {
        nameFile = blobToString(contentFile, 'ISO-8859-1');
        filelines = nameFile.split('\n');
        accstoupload = new List<Account>();

        for (Integer i = 1; i < filelines.size(); i++) {
            String[] inputvalues = filelines[i].split(',');
            Account a = new Account();
            a.Name = inputvalues[0];
            a.ShippingStreet = inputvalues[1];
            a.ShippingCity = inputvalues[2];
            a.ShippingState = inputvalues[3];
            a.ShippingPostalCode = inputvalues[4];
            a.ShippingCountry = inputvalues[5];
            accstoupload.add(a);
        }
    } catch (Exception e) {
        ApexPages.addMessage(new ApexPages.Message(ApexPages.severity.ERROR,
            'An error has occurred reading the CSV file: ' + e.getMessage()));
    }

    try {
        insert accstoupload;
    } catch (Exception e) {
        ApexPages.addMessage(new ApexPages.Message(ApexPages.severity.ERROR,
            'An error has occurred inserting the records: ' + e.getMessage()));
    }
    return null;
}

This works fine for straightforward files without embedded commas or quotes, but it lacks the more robust CSV parsing shown in the LWC/Apex example above.

Key Takeaways

  • Use lightning-file-upload for a modern, drag-and-drop-friendly upload experience.
  • Convert the uploaded Blob to a string with the correct character encoding before parsing.
  • Never assume a plain split(',') is safe — always account for quoted fields containing commas.
  • Batch your SOQL queries using sets and maps instead of querying inside loops.
  • Use external IDs with upsert so repeated file uploads don't create duplicate records.
  • Wrap both the parsing and DML logic in try/catch blocks so partial failures are reported clearly to the user.

With this pattern, you get a reusable, safe, and user-friendly way to bulk-load data into Salesforce straight from a CSV file — no Data Loader required.

Thursday, July 30, 2026

What is Selective Query in Salesforce

 In Salesforce SOQL, a Selective Query is a query that efficiently uses indexes to return a small subset of records, avoiding full table scans and staying within governor limits (especially the 100,000-row limit).

✅ What Is a Selective Query?

A selective query is one that:

  • Uses indexed fields in the WHERE clause

  • Returns a small result set

  • Avoids performance issues or timeouts during execution

🚫 Non-Selective Query (Bad Example)

sql
SELECT Id, Name FROM Contact WHERE FirstName LIKE '%John%'
  • %John% disables index usage

  • Can trigger Too many query rows: 100001


✅ Selective Query (Good Example)

sql
SELECT Id, Name FROM Contact WHERE AccountId = '001ABC123456789' LIMIT 100
  • AccountId is an indexed field

  • Returns limited records

🔍 Selectivity Rule of Thumb

For standard or custom objects:

  • For a query to be selective, it should filter on an indexed field and return:

    • < 10% of records (if > 1 million records)

    • < 100,000 total rows returned

📌 Indexed Fields (Default)

TypeExamples
StandardId, Name, OwnerId, CreatedDate
CustomFields marked as External ID or Unique
System-createdRecordTypeId, MasterDetailId, LookupId

You can also request a custom index from Salesforce Support.

🛠️ Tools to Analyze Query Selectivity

  1. Query Plan Tool in Developer Console:

    • Shows if the query is selective

    • Use: Query Plan tab after pasting a SOQL query

  2. Explain Plan in Workbench:

    • Go to Utilities → Query Plan

    • Paste SOQL to check cost & cardinality

🧠 Tips to Make Queries Selective

  • Use indexed fields in filters

  • Use = or IN operators (not !=, NOT IN, or LIKE '%abc')

  • Avoid filtering on formula fields (not indexable)

  • Use skinny tables or custom indexes for reporting-heavy orgs

  • Always bulkify Apex logic around SOQL

🧪 Example: Bulk-Safe + Selective

apex
// Query using indexed field and limiting fields fetched List<Case> cases = [ SELECT Id, Status, Subject FROM Case WHERE AccountId IN :accountIds AND CreatedDate = THIS_MONTH LIMIT 500 ];

Saturday, July 25, 2026

lets Built a Multi-Agent Telecom AI Assistant on Salesforce — Then Gave Claude Access to It Too

Lets Built a Multi-Agent Telecom AI Assistant on Salesforce — Then Gave Claude Access to It Too

Most Agentforce demos you see online are a single agent answering FAQ questions. This is a capstone project I built to go further: a team of four coordinated agents that diagnose network outages, explain bills, walk customers through SIM replacement with real identity verification, and — the part I'm most excited to share — can be reached directly from Claude through a Salesforce MCP server. Here's the full build, architecture, and what I learned shipping it.

TL;DR: One customer-facing Orchestrator agent routes conversations to three specialists — Network Diagnostics, Billing & Plan Advisor, and Technical Support — all written in Agent Script, grounded in real Salesforce data, and backed by RAG for device manuals and policy documents. It's deployed two ways: as a live chat widget on an Experience Cloud self-service portal, and as a connector any Claude user can talk to via a Salesforce MCP server. Compliance isn't an afterthought — SIM replacement enforces KYC verification and OTP validation in the script itself, with mandatory human escalation on any failure.

The Business Problem

Telecom support has a shape most industries don't: the same customer conversation can touch a network outage, a confusing bill, and a broken modem in the same five minutes — and somewhere in there they might also need to replace a lost SIM, which means real identity verification, not just a friendly chatbot. A single-purpose bot answers one of those well and shrugs at the rest. The brief behind this capstone was explicit about that: multi-agent orchestration for support, diagnostics, and billing; RAG grounding against device manuals and service policies; omnichannel delivery through an Experience Site; and Agent Script-driven workflows for identity verification, SIM replacement, and number portability — the exact places where a hallucinating agent would be a genuine liability, not just an inconvenience.

Architecture at a Glance

The design keeps one agent owning the whole conversation. Instead of bouncing the customer between bots, the Orchestrator delegates to specialists as tools and keeps the context and the relationship — so a customer can mention a network issue and a billing question in the same thread without repeating themselves.

Architecture diagram showing channels, the orchestrator agent, three specialist subagents, the actions layer, and the Salesforce data layer

Four layers, top to bottom:

  • Channels — the Experience Site chat widget, Claude via MCP, and voice.
  • Orchestrator — a single Agent Script file that greets the customer, verifies identity, classifies intent, and hands off.
  • Specialist subagents — each scoped to one domain, each with its own reasoning instructions and its own action list.
  • Actions and data — Apex invocable actions, Flow actions, and knowledge/RAG retrieval, all sitting on top of standard field-level security so the agent never sees more than the requesting user could.

Meet the Agent Team

Agent Job Guardrails baked in
Orchestrator (Agent Router) Greets the customer, confirms identity, classifies intent, routes to the right specialist, logs every routing decision Never exposes internal object or API names to the customer; never invents an answer instead of pulling real data
Network Diagnostics Checks outages by zip code, walks through device-specific troubleshooting, opens a ticket only after a remediation attempt fails Won't open a duplicate ticket for an outage already being tracked
Billing & Plan Advisor Explains invoices line by line, recommends plans based on real usage history, executes plan changes Only processes a change through the dedicated Flow action, and only after the customer explicitly confirms the new plan and price
Technical Support Device setup, Wi-Fi/modem troubleshooting, broadband installation scheduling, SIM replacement and activation Never bypasses KYC, never skips OTP, caps retries at three attempts, escalates every security failure to a human

The Data Model Behind It

Nothing here is fictional plan copy — every answer the agent gives is grounded in actual records:

Object Purpose
Account (Person Account) Subscriber profile, KYC status, fraud risk flag
Product2 Plan catalog — data limit, price, contract term
Subscription__c The customer's current plan/line
Device__c Registered devices, warranty, firmware version
SIM__c SIM/eSIM status and replacement history
Invoice__c Billing history, payment status, late fees
Case Service tickets, including agent-created diagnostics
OTP_Verification__c Hashed OTP storage for identity checks
Network_Outage__c Outage data keyed by zip code

Inside the Agent Script

This is where the project earns the "engineering," not just "prompting." Here's a trimmed, cleaned-up look at the Orchestrator's router logic:

start_agent agent_router:
  label: "Agent Router"
  description: "Welcome the user and determine the appropriate subagent based on user input"

  reasoning:
    | - Always greet the customer and confirm their identity before discussing
    |   any account-specific detail.
    | - If the user asks about a network_issue -> hand off to Network_Diagnostics_Agent.
    | - If the user asks a billing_question or requests a plan_change ->
    |   hand off to Billing_Plan_Advisor_Agent.
    | - If the user asks about technical_support (device/Wi-Fi/modem) ->
    |   hand off to Technical_Support_Agent.
    | - If the user asks about sim_replacement or number_portability ->
    |   invoke the corresponding subagent.
    | - Never expose internal system names, DMO names, or raw API responses
    |   to the customer.
    | - Never answer from a generic assumption — always answer from real
    |   retrieved data.
    | - Log every routing decision via the Log_Interaction action before closing.

    actions:
      go_to_Network_Diagnostics_Agent: @utils.transition to @subagent.Network_Diagnostics_Agent
      go_to_Billing_Plan_Advisor_Agent: @utils.transition to @subagent.Billing_Plan_Advisor_Agent
      go_to_Technical_Support_Agent: @utils.transition to @subagent.Technical_Support_Agent

Notice the two rules that do the most work: never expose internal system names and never answer from assumption. Those two lines are the difference between a demo and something you'd actually let a paying customer talk to — they force every specialist to ground its answer in a real Apex or Flow action instead of the model's own guess.

Compliance You Can Trust: The SIM Replacement Walkthrough

SIM replacement is the highest-stakes flow in the whole agent — get it wrong and you've handed a stranger someone else's phone number. So it's scripted deterministically, not left to the model's judgment:

Flow diagram of the SIM replacement process: collect details, look up profile, check KYC status, send OTP, validate OTP with a three-attempt limit, then create and activate the new SIM

The rules that make this safe are stated explicitly in the script itself, not just implied by good intentions:

  • Never bypass KYC verification. If KYC_Status__c isn't Verified, processing stops immediately and the case escalates to a human — no exceptions.
  • Never skip OTP validation, and never generate a random OTP and hand it to the customer directly — it's only ever sent to the registered email.
  • Cap retries at three attempts. A fourth failed OTP entry escalates automatically.
  • Every security-related failure escalates. The agent is never the last line of defense on identity.

That's the pattern worth stealing for any regulated workflow you put behind an agent: let the LLM handle the conversation, but let hard-coded logic — not model judgment — own the parts where being wrong actually hurts someone.

Going Live: Two Ways In

1. The Experience Cloud Self-Service Portal

This is the channel real customers use:

  1. Enable Messaging Settings in Setup.
  2. Configure Routing Configuration.
  3. Create a Queue with Messaging Session as a selected object.
  4. Build and publish a site in Experience Builder.
  5. Commit and activate the service agent you want to deploy.
  6. Create a new channel under Messaging Settings.
  7. Create and publish an Embedded Service Deployment.

Once that's live, the chat widget on the portal is the Orchestrator — customers never know how many specialist agents are working behind it.

2. Bringing the Agent to Claude via MCP

This is the part worth a second look, because it's not something most Agentforce tutorials show: the same agent, reachable from Claude through a standard Salesforce MCP server.

  1. In Setup, go to External Client App Manager and create a new external client app (e.g., "Claude Integration").
  2. Enable OAuth settings, and set the callback URL to Claude's standard MCP callback: https://claude.ai/api/mcp/auth_callback.
  3. Grant the OAuth scopes that matter here: Perform requests at any time (refresh_token, offline_access) and Access Salesforce hosted MCP servers (mcp_api).
  4. Under Security, require PKCE for supported authorization flows, and issue JWT-based access tokens for named users.
  5. Copy the Consumer Key and Consumer Secret.
  6. In Claude, go to Settings → Connectors → Add Custom Connector, and paste in the org's MCP URL along with the Client ID and Client Secret.
  7. Set tool permissions to Always allow, start a new chat, and confirm the connector is enabled for that conversation.

From that point on, anyone with the right access can ask Claude a question and have it reach into the same Salesforce org — same data model, same guardrails — through the org's MCP server, instead of only through the chat widget on the portal.

What Building This Taught Me

A few things stood out that don't show up in the Trailhead version of Agentforce:

  • Guardrails belong in the script, not the prompt. "Never bypass KYC" reads like an instruction, but it only works because it's enforced as a deterministic branch, not a polite request to the model.
  • Multi-agent only feels seamless if one agent owns the conversation. The moment you let the customer talk to three separate bots instead of one Orchestrator quietly delegating, the experience falls apart.
  • MCP turns an agent into a platform. Once the Orchestrator is reachable through a standard MCP server, it stops being "a chatbot on our website" and becomes a capability other tools — like Claude — can use directly.

What's Next

Number portability is the next workflow to script the same way SIM replacement was — same compliance shape, different regulatory checks. I'd also like to push Data Cloud further upstream, so the Network Diagnostics subagent is reasoning over live telemetry instead of a periodically-updated outage object.

If you're building something similar — or you've hit the same "guardrails in the script vs. the prompt" question — I'd love to hear how you approached it in the comments.

Agent Script for Developers: Coding Agentforce Agents Like Real Software

 

Agent Script for Developers: Coding Agentforce Agents Like Real Software

Building an agent by clicking through Agentforce Builder works fine until your logic gets specific — "offer free shipping only if the order total is over $100 AND the customer is a loyalty member AND it's not already on backorder." At that point, natural-language instructions to an LLM start to feel like duct tape. Agent Script is Salesforce's answer: a real, readable scripting language purpose-built for agents.

TL;DR: Agent Script is a declarative, human-readable language for defining Agentforce agents — their subagents (formerly called topics), instructions, variables, and actions — as code instead of only as clicks. It blends natural-language reasoning instructions with deterministic if/else logic, lives in a .agent file inside your Salesforce DX project, and is fully supported in VS Code with syntax highlighting and validation. If you've ever wished you could put an agent under version control, this is how.

Why Developers Should Care

Agentforce Builder's canvas view is genuinely good for admins — natural language in, working agent out. But every agent eventually needs the same things any serious codebase needs: predictable branching logic, reusable structure, code review, and a diff you can actually read. Agent Script gives you all of that because, under the hood, every agent you build in Agentforce — whether through chat, canvas, or script — is Agent Script. The Script view just lets you work with it directly instead of through a UI abstraction.

That matters for a very practical reason: it puts agent definitions in your Salesforce DX project, next to your Apex and LWC, where they can be versioned, code-reviewed, and deployed the same way as everything else you ship.

The Building Blocks of a Script File

An Agent Script file is organized into a small number of named blocks. Once you recognize them, most scripts read top to bottom without much translation:

Block What it holds
config Core agent settings — developer_name, agent_label, description, agent_type, and which Salesforce user the agent runs as.
system Agent-wide instructions and required messages like welcome and error.
variables Named state the agent tracks across a conversation, referenced anywhere as @variables.<name>.
subagent A self-contained unit of behavior — its own description, instructions, and available actions. This is where most of the actual logic lives.
start_agent The entry point every conversation begins at; decides which subagent should handle the user's request.
connected_subagent A reference to a different Agentforce agent in your org, so one agent can delegate work to another.

If "subagent" sounds like a rename, it is — Salesforce renamed topics to subagents in April 2026 with no functional change, so don't be surprised if you see both terms depending on which doc or org version you're looking at.

A Worked Example: Order Status, in Script

Let's script a small piece of the same order-status scenario from our last post — but this time controlling when the agent should hand off to a human instead of just answering.

config:
  developer_name: "Order_Support_Agent"
  agent_label: "Order Support Agent"
  agent_type: "AgentforceServiceAgent"
  default_agent_user: "order_support_agent_user@yourorg.com"
  description: "Helps customers check order status and escalates delayed orders to a human agent."

system:
  welcome: "Hi! I can help you check on an order — what's your order number?"
  error: "Something went wrong on my end. Let me connect you with a teammate."

variables:
  order_status: string
  days_delayed: number

subagent order_lookup:
  description: "Looks up an order's status and delivery estimate when the customer provides an order number."

  reasoning:
    instructions:
      "Ask for the order number if it hasn't been provided ->
       If @variables.days_delayed > 3 | Apologize for the delay before sharing status details.
       Otherwise | Share the order status plainly and offer to help with anything else."
    actions:
      - get_order_status
      - transition_to_escalation

  actions:
    get_order_status:
      description: "Calls Apex to retrieve status, delivery estimate, and delay in days for an order number."
      target: apex://OrderStatusAction

subagent escalation:
  description: "Hands off to a human agent when a delay is significant or the customer asks for a person."
  reasoning:
    instructions:
      "If @variables.days_delayed > 7 | Explain that a specialist will follow up and transition immediately.
       Otherwise | Ask one clarifying question before deciding whether to escalate."

A few things worth noticing:

  • The line with -> inside reasoning.instructions is where Agent Script earns its keep. Everything before it can be plain natural language; everything after can be a hard conditional evaluated against a real variable — not something the LLM has to infer from conversation history.
  • get_order_status here points at apex://OrderStatusAction — the exact custom Apex action with @InvocableMethod we built in the previous post. Agent Script doesn't replace Apex actions; it's the orchestration layer that decides when and whether to call them.
  • Variables like days_delayed give the agent reliable memory instead of leaning on the LLM to remember and recompute values mid-conversation.

Three Ways to Write It (All Produce the Same Thing)

Salesforce is intentionally flexible about how you author a script:

  1. Chat with Agentforce and describe what you want ("if the order's more than a week late, hand it straight to a human") — Agentforce converts that into subagents, actions, and instructions for you.
  2. Canvas view — a visual, block-based editor where / inserts logic patterns like if/else and @ inserts references to subagents, actions, or variables.
  3. Script view — write and edit the raw .agent file directly, with the same syntax highlighting and autocomplete you'd expect from any language extension in VS Code.

All three are the same underlying artifact. You can start in canvas view and drop into script view the moment the logic gets too specific for clicking — and back again.

Working in VS Code with Agentforce DX

If you'd rather live in your editor than in Setup, Agentforce DX brings the whole workflow local:

  1. Generate or retrieve an authoring bundle for your agent into your DX project — it lands at force-app/main/default/aiAuthoringBundles/<Agent_API_Name>/<Agent_API_Name>.agent.
  2. Edit the .agent file directly, or open the Agentforce Vibes panel to describe changes in natural language and let it edit the script for you.
  3. Validate the file before you publish — VS Code's AFDX: Validate This Agent command (or the equivalent CLI command) checks that the script compiles and flags syntax errors with their exact location.
  4. Preview the agent from the script file itself to test behavior before publishing it back to your org.

One habit worth building early: validate often, not just before a deploy. Agent Script errors are usually small — a missing colon, a typo in a block name — and they're far easier to fix one at a time than after you've written fifty lines on top of a broken block.

Where This Fits with What You Already Know

If you've spent time in Flow, a lot of this will feel familiar wearing different clothes: subagents are a bit like Flow's screen-by-screen structure, reasoning instructions are your decision logic, and actions are the same invocable Apex, Flow, and prompt-template building blocks Agentforce already supports. The real shift is that it's all expressed as one readable, versionable file instead of a set of linked records you navigate by clicking.

The Bottom Line

Agent Script doesn't replace Agentforce Builder — it's what Agentforce Builder is writing on your behalf every time you build an agent through chat or canvas. Once your agent's logic outgrows what feels safe to leave entirely to LLM interpretation, dropping into Script view (or straight into VS Code with Agentforce DX) gives you the same rigor you already expect from Apex: version control, code review, and behavior you can actually predict.

Have you tried writing Agent Script directly, or are you still building through canvas view? Let me know how it's going in the comments below.