SSmartLite Academy
0 pts
Trail MapTrailsIndustriesUse CasesComponents
Appearance
Your points 0 pts

Extend the Platform · For Developers & Admins

Custom Classes & Pages

Build Your Own Screens, Backed by Governed Code

When a standard record page, related list, or Workflow Rule action just isn't enough, Custom Classes and SmartLite Components let you write your own small scripts and screens — safely boxed in and limited, so they can never affect anything outside your own company's data. Heads up: unlike most other trails, this one involves writing some real code, so it's best suited to admins who are comfortable with that.

⏱ ~50 min to complete60 pts available

Screens are SmartLite Components

Every screen and record-page panel on this trail is a SmartLite Component, built under Setup → Customization → SmartLite Components: a template, a small JavaScript controller, and an @class import for each class method it calls. To learn how they work, start with the SmartLite Components Guide; for the language behind the classes, the Custom Class Code Guide. Classic single-file Custom Pages made earlier keep working.

About & Business Case #

RS
Ravi Shah · Implementation Partner

"Our support desk wants to type an account name and instantly see every contact under it — before they've even opened the account record. A related list only shows contacts once you're already on that one account's page. On the old platform we'd write an Apex controller and a Lightning page for this. Can we do that here without standing up a whole dev environment?"

Most day-to-day work fits the standard patterns you already know — record detail pages, related lists, list views, Reports. But some needs genuinely don't: a search screen that spans multiple objects, a dashboard-style widget with custom logic, or a lookup a rep needs before a record even exists.

SmartLite CRM has a safe answer for exactly this gap: Custom Classes (small scripts that run behind the scenes, safely boxed in) and SmartLite Components (screens and record-page panels you build yourself that call a Custom Class to fetch data). The same Custom Class can also be attached to a Workflow Rule or Journey Builder step, so the script you write once can run automatically whenever a record is saved, not just when a user opens a screen.

What you'll be able to do

Build a real cross-object search screen — reachable from your app's nav like any other tab — backed by a governed script you write yourself, with zero platform code changes.

Implementation #

Two building blocks, one governed sandbox #

Custom Classes and SmartLite Components are separate pieces that plug together — you'll usually build a Class first, then a component (or a Workflow Rule action) that calls it.

Custom ClassA saved, safely boxed-in script that reads and writes records through the same permission-checked system the rest of the CRM uses — it can never see another company's data, and it can never touch files, the internet, or run system commands. Written under Setup → Developer Console → Saved Scripts, admin-only.
paramsWhen a component calls a class method, whatever it passes in (like a typed search term) arrives as the method's params map. A class run from a Workflow Rule or the Run button gets nothing (its execute() takes no argument), and a batch job gets its records instead: those are different ways of triggering the same class.
SmartLite ComponentA screen (an app page with its own tab) or a record-page panel you build yourself, under Setup → Customization → SmartLite Components. It runs inside a locked-down, safely boxed-in area with no direct access to the internet — its only way to fetch or change data is by calling a Custom Class.
import x from '@class/Class.method'The one bridge a component has back to the server: one import line per method. Call it with a params object, await x({ … }), and you get back whatever the method returns, as plain data the template shows.
"Run Custom Class" actionA Workflow Rule or Journey Builder step type that runs a Custom Class automatically after a record is created or updated — no params, since there's no user typing anything in.
Where a Custom Class can runGets params?Triggered by
SmartLite Component as an app page (on demand)YesA user action on the screen, e.g. clicking Search, or the screen opening
SmartLite Component on a record page (on demand)YesThe component calling a method it imports, usually with this.recordId — see the SmartLite Components Guide
Workflow Rule / Journey Builder — "Run Custom Class"NoRecord created/updated (after save)
Batch mode (from Developer Console)NoManually started, processes records in chunks

Same script, different doors

Write a class defensively (read params?.x, so a call with no parameters doesn't fail) and you can reuse the same class from a component and a Workflow Rule, instead of writing it twice.

Everything has a usage limit

Every class run — whether from a component, a Workflow Rule, or a batch job — counts against your company's daily script-run limit, and each run has to finish within 15 seconds. A component that calls a class on every keystroke will use up that limit fast; only call it on an explicit action like a Search button.

🌱 Beginner: the three verbs every script uses #

Before any of the bigger builds below, every Custom Class leans on the same three data operations — all of them permission-checked, org-isolated, and impossible to bypass with raw SQL (there isn't any).

Create a recordPlatform.insert('lead', [first_name: 'Priya', last_name: 'Nair', email: 'priya@x.com']) — returns the new record's ID.
Update a recordPlatform.update(leadId, [status: 'Contacted']) — only the fields you pass are changed; everything else is left alone.
Read records backPlatform.query('lead', 'status', 'New') — filtered by one field, or Platform.getRecord('lead', leadId) for a single record by ID.

Try it now

Setup → Customization → Developer Console → Execute Script. Paste CRM.log(CRM.insert('lead', [first_name: 'Test', last_name: 'Lead', email: 'test@example.com'])) and click Execute: the output shows the new Lead's ID, and the Lead is there. In this tab the toolbox is called CRM; inside a saved class it is Platform.

1 Intermediate: build the account → contacts search screen #

You'll build exactly the screen Ravi asked for in the About tab: type part of an account name, see every contact under every matching account.

Write the Custom ClassSetup → Customization → Classes → New Class. Paste the AccountContactSearch class from example 21 below: its search(params) method reads params.q, finds matching Accounts with Platform.query('account'), then each one's Contacts with Platform.query('contact', 'account_id', a.hex_id), and returns them.
Save it, ActiveSaving compiles the class and reports any mistake straight away. An inactive class can't be called from a component or a Workflow Rule — leave Active ticked.
Build the screenSetup → Customization → SmartLite Components → New Component. API name accountContactSearch, tick App page, and paste example 21's template and JavaScript: a search box and button, and import search from '@class/AccountContactSearch.search' called with { q: this.q }. Click Check, then Save.
Give it a home in the navSetup → Configuration → Tab Manager → New Custom Tab. Choose Page Tab, and pick Account Search under SmartLite Components (its address is /c/accountContactSearch). Assign the new tab to whichever app your team lives in.
Try it as a regular userOpen the tab, type part of an account name, click Search. Building a screen needs an admin; using it doesn't — any teammate with the tab in their nav can.
(Stretch) Wire it into automation tooOpen a Workflow Rule on Account, add a "Run Custom Class" action, and point it at AccountContactSearch. It'll now also fire automatically whenever a matching Account is saved — remember, in that context the class gets no params, so guard for that (params?.q) if you're reusing the same class.

2 Advanced: process thousands of records with Batch Mode #

Some jobs are too big for one 15-second run — like backfilling a field across every Lead your company has ever created. Batch Mode splits the work into chunks and runs them automatically, one after another, until every record has been through the script.

Write a batch-shaped scriptNew Script, check Batch Mode. Set Object API Name to lead and Chunk Size to 200 (the default). The class's execute(batch) method gets the current chunk's records as a list: class LeadSourceFix { def execute(batch) { batch.each { r -> if (!r.source) Platform.update(r.hex_id, [source: 'Other']) } } } (example 1 below is the complete version).
Save, mark Active, click Run as BatchThis returns immediately — it just starts the job. The actual chunks are processed in the background every 30 seconds, not all at once.
Watch it under Recent Batch JobsStatus moves PENDING → RUNNING → COMPLETED, with a running count of records processed and chunks completed. A failed chunk retries automatically (with a short wait that doubles each time) before giving up — you don't have to babysit it.

Fresh budget every chunk

The 150-row/15-second limits reset for every chunk, not once for the whole job — a batch job over 10,000 records just means 50 chunks of 200, each with its own full budget, not one shared one.

3 Pro: run a batch script automatically, every day #

Turn the manual "Run as Batch" click from the previous step into a standing job that fires on its own — no one has to remember to start it.

Open your batch-mode scriptIt must already have Batch Mode checked — Run Daily is a property of a batch script, not a separate feature.
Check "Run Daily"A new field appears: Time (UTC) — pick the hour of day (e.g. 02:00 UTC) you want it to start.
Save — that's itFrom now on, every day at your chosen hour, a new batch run starts by itself. The script list shows a "Daily HH:00 UTC" badge on any script scheduled this way, so you can see at a glance what's running automatically.

It never stacks runs

If yesterday's scheduled run is still going when today's trigger time arrives, today's run is quietly skipped rather than starting a second one on top of it — you'll never get two overlapping jobs fighting over the same records.

4 Syntax reference: declaring a variable, building a class, calling it #

Every Custom Class is Groovy — close to Java, forgiving about types. Here's the three things you'll write over and over.

Declaring a variableStart a new variable with def or with a type: def name = 'Priya' or String name = 'Priya', int count = 0, List rows = []. Both work. Use def for a variable holding one of your own model classes (below) and for a caught error, because the sandbox refuses calls on those types. The Code Guide explains why, with the whole language from variables to a complete class.
Building a classclass ClassName { def methodName(params) { ...your logic...; return someValue } } — the class's own name in the code is its API name; there's no separate name field to fill in anywhere. Whatever the method's last line evaluates to (or an explicit return) becomes the result. Platform — the toolbox with insert/update/query/getRecord/log/httpGet etc. — is simply available inside any method, with nothing to import or declare.
Calling a classFour doors, one script — see the table above for the full picture. From code, a class can also call its own other methods directly (helper()), or call a different, already-saved class by name: Platform.callClass('OtherClassName', 'someMethod', [key: 'value']) — same governed budget as the caller, just one extra level of nesting (capped at 5 deep, so a class can't accidentally call itself in an infinite loop).

Minimal working example

class LeadService {
    def getSummary(params) {
        def lead = Platform.getRecord('lead', params.leadId)
        return [ name: lead.first_name + ' ' + lead.last_name, status: lead.status ]
    }
}
Save this under Setup → Customization → Classes. From a SmartLite Component: import getSummary from '@class/LeadService.getSummary';, then await getSummary({ leadId: this.recordId }).

5 Typed model classes — a real class with getters and setters #

A method doesn't have to return a plain list of loose key/value pairs. It can build and return a list of a proper model class you define yourself, with real fields and real get/set methods — closer to how you'd shape data in a typed language.

Declare a second class in the same scriptYour main callable class goes first (its name is what gets saved as the API name); a plain model class can follow it in the same source file.
Instantiate it with defdef b = new Breed() — not Breed b = new Breed(). As above, this is what lets the safety sandbox allow it.
Computed properties are freeA getter doesn't have to just return a stored field — it can compute something on the fly, like joining a list into a display string. A component's template (step 7) can bind to a computed getter exactly the same way it binds to a plain one.
class DogBreeds {
    def getBreeds(params) {
        def list = []
        def b = new Breed()
        b.setBreedName('Husky')
        b.setSubBreeds(['sub-breed'])
        list.add(b)
        return [ breeds: list ]
    }
}
class Breed {
    private String breedName
    private List subBreeds
    String getBreedName() { return breedName }
    void setBreedName(String v) { this.breedName = v }
    List getSubBreeds() { return subBreeds }
    void setSubBreeds(List v) { this.subBreeds = v }
    String getSubBreedsText() { return subBreeds ? subBreeds.join(', ') : '-' }  // computed
}

6 Call a third-party web service (Named Credentials) #

A Class can reach out to an external API — a shipping-rate lookup, a Slack post, a firmographic enrichment service — but it never sees a raw URL or a secret. Both live in a Named Credential, resolved automatically at call time.

Register the credentialSetup → Named Credentials → New. Give it an API name (e.g. shippingApi), the base URL, and how to authenticate (None / API Key Header / Bearer Token / Basic).
Call it from a Classdef res = Platform.httpGet('shippingApi', '/rates?zip=94105') — also httpPost/httpPut/httpDelete, all returning [status, body, headers]. The auth header is injected for you.
It's still governedOnly https://, only public addresses (no reaching your own internal network), no more than 10 outbound calls per script run, and a strict response-size/time cap — the same governed-sandbox philosophy as everything else in this trail.

7 Render results with an LWC-style template #

A SmartLite Component doesn't need hand-written DOM code to show a list or a value — its template uses the same directive names as a Lightning Web Component template (for:each, if:true, {value}), so if you already know LWC, you already know this. This screen shows DogBreeds from step 5 when it opens: tick App page, paste both files, and add a tab for it.

dogBreeds.html
<template>
  <template if:true={loading}><p class="sl-muted">Loading…</p></template>
  <template if:false={loading}>
    <table>
      <template for:each={view.breeds} for:item="breed">
        <tr key={breed.breedName}><td>{breed.breedName}</td><td>{breed.subBreedsText}</td></tr>
      </template>
    </table>
    <template if:false={view.breeds}><p>No breeds found.</p></template>
  </template>
</template>
dogBreeds.js
import getBreeds from '@class/DogBreeds.getBreeds';

export default class DogBreeds extends SmartLiteComponent {
  view = { breeds: [] };
  loading = true;

  async connectedCallback() {
    try {
      this.view = await getBreeds({});
    } finally {
      this.loading = false;
    }
  }
}

No innerHTML, ever

The template's markup is read natively by the browser and bound with real DOM calls — nothing here is built by concatenating an HTML string, so data can never accidentally be parsed as markup.

8 Example library: seven classes you can copy #

Complete classes, one for each common job. Every one of them was compiled and run in the real sandbox against the standard Lead, Account, Contact, Opportunity and Case fields, so you can paste one under Setup → Classes and it will save as is. Change the field names if your org uses custom fields (they end in __c).

What each row looks like

Platform.query(...) returns up to 500 records, each a map of field name → value plus hex_id (the record's ID). Platform.getRecord(...) returns one record with its ID under id, or null if it doesn't exist or you can't see it. Field values come back as text, so convert numbers before adding them up.

A. Read and summarise: pipeline by stage #

Called from a SmartLite Component. Groups every Opportunity by stage and totals the amounts, largest stage first. Shows groupBy, collect, sum and a private helper method.

class PipelineSummary {
    def getByStage(params) {
        def opps = Platform.query('opportunity')
        def byStage = opps.groupBy { it.stage ?: 'No stage' }
        def rows = byStage.collect { stage, list ->
            [ stage: stage, count: list.size(), total: list.sum { toAmount(it.amount) } ]
        }
        return [ stages: rows.sort { a, b -> b.total <=> a.total } ]
    }
    // Amounts are stored as text; blank counts as 0
    private toAmount(value) {
        return value ? value.toString().toBigDecimal() : 0
    }
}

The SmartLite Component (an app page) that shows it, built like step 7:

pipelineSummary.html
<template>
  <template if:true={loading}><p class="sl-muted">Loading…</p></template>
  <template if:false={loading}>
    <table>
      <tr><th>Stage</th><th>Deals</th><th>Total</th></tr>
      <template for:each={view.stages} for:item="s">
        <tr key={s.stage}><td>{s.stage}</td><td>{s.count}</td><td>{s.total}</td></tr>
      </template>
    </table>
  </template>
</template>
pipelineSummary.js
import getByStage from '@class/PipelineSummary.getByStage';

export default class PipelineSummary extends SmartLiteComponent {
  view = { stages: [] };
  loading = true;

  async connectedCallback() {
    try {
      this.view = await getByStage({});
    } finally {
      this.loading = false;
    }
  }
}

Called from a SmartLite Component form. Checks its input, skips companies that already exist, then creates an Account and its first Contact linked by account_id. Throwing an exception stops the class and shows the message to the caller.

class AccountOnboarding {
    def create(params) {
        if (!params?.company) throw new IllegalArgumentException('company is required')
        def existing = Platform.query('account', 'name', params.company)
        if (existing) return [ accountId: existing[0].hex_id, created: false ]
        def accountId = Platform.insert('account', [ name: params.company, industry: params.industry ])
        def contactId = Platform.insert('contact', [
            first_name: params.firstName ?: 'Primary',
            last_name : params.lastName ?: 'Contact',
            email     : params.email,
            account_id: accountId
        ])
        Platform.log('onboarded ' + params.company)
        return [ accountId: accountId, contactId: contactId, created: true ]
    }
}

A form that calls it, as an app page. When the class throws, the component's await fails and shows the message:

onboardCustomer.html
<template>
  <form onsubmit={handleSubmit}>
    <input name="company" placeholder="Company" value={form.company} oninput={handleInput}>
    <input name="lastName" placeholder="Contact last name" value={form.lastName} oninput={handleInput}>
    <input name="email" placeholder="Contact email" value={form.email} oninput={handleInput}>
    <button class="sl-primary" disabled={busy}>Onboard</button>
  </form>
  <p class="message">{message}</p>
</template>
onboardCustomer.js
import create from '@class/AccountOnboarding.create';

export default class OnboardCustomer extends SmartLiteComponent {
  form = { company: '', lastName: '', email: '' };
  message = '';
  busy = false;

  handleInput(event) {
    this.form = { ...this.form, [event.target.name]: event.target.value };
  }

  async handleSubmit() {
    this.busy = true;
    try {
      const r = await create(this.form);
      this.message = r.created ? 'Account and contact created.' : 'That company already exists.';
      if (r.created) this.openRecord(r.accountId);
    } catch (e) {
      this.message = e.message;      // e.g. "company is required"
    } finally {
      this.busy = false;
    }
  }
}

C. Clean data in bulk: a batch class #

Save it with Batch Mode on and Object API Name lead. Each chunk of leads arrives as the batch argument. The second execute(), with no argument, lets the Run button reuse the same logic on today's new leads.

class LeadCleanup {
    // Run as Batch: called once for every chunk of leads
    def execute(batch) {
        def fixed = 0
        batch.each { lead ->
            def changes = [:]
            def email = lead.email?.trim()?.toLowerCase()
            if (email && email != lead.email) changes.email = email
            if (!lead.source) changes.source = 'Other'
            if (lead.company) changes.company = lead.company.trim()
            if (changes) {
                Platform.update(lead.hex_id, changes)   // only the fields in changes are touched
                fixed++
            }
        }
        Platform.log('cleaned ' + fixed + ' of ' + batch.size() + ' leads')
        return fixed
    }
    // Run button: the same clean-up over new leads only
    def execute() {
        return execute(Platform.query('lead', 'status', 'New'))
    }
}

D. One class calling another: score and route leads #

Save LeadScorer first, then HotLeadRouter. The router can run from the Run button or from a Workflow Rule's "Run Custom Class" action. It asks the scorer for each new lead's score, saves the score, and moves leads scoring 50 or more to Working. The call shares the router's limits, and calls can go at most 5 classes deep.

class LeadScorer {
    def score(params) {
        def lead = Platform.getRecord('lead', params.leadId)
        if (!lead) return 0
        def points = 0
        if (lead.email)   points += 20
        if (lead.phone)   points += 10
        if (lead.company) points += 20
        if (lead.source in ['Referral', 'Partner']) points += 30
        return points
    }
}
class HotLeadRouter {
    // Run button, or a Workflow Rule's "Run Custom Class" action
    def execute() {
        def hot = 0
        Platform.query('lead', 'status', 'New').each { lead ->
            def points = Platform.callClass('LeadScorer', 'score', [ leadId: lead.hex_id ])
            def changes = [ lead_score: points ]
            if (points >= 50) {
                changes.status = 'Working'
                hot++
            }
            Platform.update(lead.hex_id, changes)
        }
        Platform.log(hot + ' hot leads moved to Working')
        return hot
    }
}

A workflow run doesn't know which record fired it

A class run by a Workflow Rule gets no arguments, so it can't see the record that was just saved. Write it to look records up itself, as HotLeadRouter does with Platform.query. Also keep each run under 150 inserts, updates and deletes: a big job belongs in Batch Mode (example C).

E. Call an outside service: enrich an Account #

Needs a Named Credential with API name enrichmentApi (step 6). Reads the Account's website, asks the service about that domain, and copies back the industry and employee count. Always check res.status before using res.body.

class CompanyEnrichment {
    def enrich(params) {
        def account = Platform.getRecord('account', params.accountId)
        if (!account?.website) return [ ok: false, message: 'Account has no website' ]
        def domain = account.website.replaceAll('^https?://', '').replaceAll('/.*$', '')
        def res = Platform.httpGet('enrichmentApi', '/companies/find?domain=' + domain)
        if (res.status != 200) {
            Platform.log('enrichment failed: HTTP ' + res.status)
            return [ ok: false, message: 'Lookup failed (' + res.status + ')' ]
        }
        def changes = [:]
        if (res.body.industry)  changes.industry  = res.body.industry
        if (res.body.employees) changes.employees = res.body.employees
        if (changes) Platform.update(params.accountId, changes)
        return [ ok: true, updated: changes.keySet().toList() ]
    }
}

The path and the industry/employees names in the response depend on the service you use: change them to match its documentation.

F. Return a model class: an open-cases board #

Builds one CaseRow per open Case and sorts High priority first. The getters become the names the page binds to (getUrgent() → urgent), including the computed ones.

class CaseBoard {
    def getOpenCases(params) {
        def rows = []
        Platform.query('case').findAll { it.status != 'Closed' }.each { c ->
            def row = new CaseRow()          // def, not CaseRow row = ...
            row.setSubject(c.subject)
            row.setPriority(c.priority)
            row.setStatus(c.status)
            rows.add(row)
        }
        return [ cases: rows.sort { a, b -> a.getRank() <=> b.getRank() } ]
    }
}
class CaseRow {
    private String subject
    private String priority
    private String status
    String getSubject() { return subject }
    void setSubject(String v) { this.subject = v }
    String getPriority() { return priority ?: 'Medium' }
    void setPriority(String v) { this.priority = v }
    String getStatus() { return status }
    void setStatus(String v) { this.status = v }
    int getRank() { return ['High': 0, 'Medium': 1, 'Low': 2][getPriority()] ?: 1 }   // computed
    boolean getUrgent() { return getPriority() == 'High' }                           // computed
}

Things the sandbox won't accept

Calls on a class name, such as Math.round(x), String.valueOf(x) or LocalDate.now(), are refused when you save. Call the method on a value instead: x.toString(), x.toBigDecimal(), new Date(). Use Platform.log(...) for output, not println.

"Temporarily unavailable" when you save or run?

Custom code is switched off on some environments while its sandbox is being strengthened. The examples are still correct: they will work once it is switched back on.

How to Use #

Day-to-day: regular users (non-technical) #

Open the tab like any otherOnce an admin has built it and added its tab, a SmartLite Component shows up in the nav exactly like Reports or Dashboard — no coding knowledge needed to use it.
Use it as designedType a search term, click the button, read the results — the underlying script is invisible to you.

50 things you can build #

Every idea below is a working example: click one to open its click-by-click steps and the complete code. All of the classes were checked in the real sandbox, using the standard Lead, Account, Contact, Opportunity, Case, Contract, Product and Campaign fields. New to the language? Start with the Custom Class Code Guide.

🗂️ Batch Classes — process thousands of records unattended #

Batch classes are saved in the Developer Console, because that is where Batch Mode is. Each chunk of records arrives as the batch argument of execute(batch); every chunk gets its own fresh limits (150 inserts/updates/deletes, 10 web calls, 15 seconds).

01
Backfill a missing field
Give every Lead with a blank Lead Source the value "Other".
▾
Steps
  1. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  2. Paste the LeadSourceBackfill code below into Source Code.
  3. Tick Batch Mode, set Object API Name to lead and Chunk Size to 200.
  4. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
  5. To repeat it every night, tick Run Daily, set Time (UTC) to 02:00 and save again.
Code
class LeadSourceBackfill {
    def execute(batch) {
        def fixed = 0
        batch.each { lead ->
            if (!lead.source) {
                Platform.update(lead.hex_id, [ source: 'Other' ])
                fixed++
            }
        }
        Platform.log('filled ' + fixed + ' of ' + batch.size())
        return fixed
    }
}
02
Nightly currency conversion
Store every Opportunity's amount in your home currency.
▾
Steps
  1. Go to Setup → Configuration → Object Manager → Opportunity → Fields → New Field. Create a Text field labelled Currency; its API name becomes currency__c.
  2. Go to Setup → Configuration → Object Manager → Opportunity → Fields → New Field. Create a Currency field labelled Amount (Home Currency); its API name becomes amount_home__c.
  3. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  4. Paste the HomeCurrencyBatch code below into Source Code.
  5. Tick Batch Mode, set Object API Name to opportunity and Chunk Size to 200.
  6. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
  7. To repeat it every night, tick Run Daily, set Time (UTC) to 01:00 and save again.
Code
class HomeCurrencyBatch {
    // Rate to convert 1 unit of each currency into the home currency (USD here).
    // For live rates, fetch them once per chunk instead: see example 18.
    def rates = [ USD: 1.0, EUR: 1.08, GBP: 1.27, INR: 0.012 ]
    def execute(batch) {
        def updated = 0
        batch.each { opp ->
            def rate = rates[opp.currency__c ?: 'USD']
            if (rate == null || !opp.amount) return
            def home = (num(opp.amount) * rate).setScale(2, 4)
            Platform.update(opp.hex_id, [ amount_home__c: home ])
            updated++
        }
        return updated
    }
    // Numbers come back as text; blank counts as 0
    private num(value) {
        return value ? value.toString().toBigDecimal() : 0
    }
}

Note: setScale(2, 4) rounds to 2 decimals, half up.

03
Mass re-score
Recalculate Lead Score on every open Lead after you change the scoring rules.
▾
Steps
  1. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  2. Paste the LeadRescoreBatch code below into Source Code.
  3. Tick Batch Mode, set Object API Name to lead and Chunk Size to 200.
  4. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
Code
class LeadRescoreBatch {
    def execute(batch) {
        def changed = 0
        batch.findAll { it.status != 'Converted' && it.status != 'Unqualified' }.each { lead ->
            def score = 0
            if (lead.email)   score += 20
            if (lead.phone)   score += 10
            if (lead.company) score += 20
            if (lead.rating == 'Hot') score += 30
            if (lead.source in ['Referral', 'Partner']) score += 20
            if (num(lead.lead_score) != score) {
                Platform.update(lead.hex_id, [ lead_score: score ])
                changed++
            }
        }
        Platform.log('rescored ' + changed + ' leads')
        return changed
    }
    // Numbers come back as text; blank counts as 0
    private num(value) {
        return value ? value.toString().toBigDecimal() : 0
    }
}
04
Archive stale Contacts
Flag Contacts nobody has changed in two years.
▾
Steps
  1. Go to Setup → Configuration → Object Manager → Contact → Fields → New Field. Create a Checkbox field labelled Archived; its API name becomes archived__c.
  2. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  3. Paste the StaleContactBatch code below into Source Code.
  4. Tick Batch Mode, set Object API Name to contact and Chunk Size to 200.
  5. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
  6. To repeat it every night, tick Run Daily, set Time (UTC) to 03:00 and save again.
Code
class StaleContactBatch {
    def execute(batch) {
        def cutoff = day(-730)          // two years ago
        def archived = 0
        batch.each { c ->
            def modified = c.sys_modified_date?.toString()?.take(10)
            if (modified && modified < cutoff && !c.archived__c) {
                Platform.update(c.hex_id, [ archived__c: true ])
                archived++
            }
        }
        return archived
    }
    // Today's date as yyyy-MM-dd (UTC), plus or minus some days
    private day(int offset) {
        return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
    }
}

Note: Classes can't read Activities yet, so "stale" here means the Contact record itself hasn't changed. Dates are compared as yyyy-MM-dd text, which sorts correctly.

05
Recalculate totals after an import
Recompute each Account's open pipeline once a bulk import of Opportunities finishes.
▾
Steps
  1. Go to Setup → Configuration → Object Manager → Account → Fields → New Field. Create a Currency field labelled Open Pipeline; its API name becomes open_pipeline__c.
  2. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  3. Paste the AccountPipelineBatch code below into Source Code.
  4. Tick Batch Mode, set Object API Name to account and Chunk Size to 50.
  5. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
Code
class AccountPipelineBatch {
    def execute(batch) {
        batch.each { account ->
            def open = Platform.query('opportunity', 'account_id', account.hex_id)
                .findAll { !(it.stage in ['Closed Won', 'Closed Lost']) }
            def total = open.sum { num(it.amount) } ?: 0
            if (num(account.open_pipeline__c) != total) {
                Platform.update(account.hex_id, [ open_pipeline__c: total ])
            }
        }
        return batch.size()
    }
    // Numbers come back as text; blank counts as 0
    private num(value) {
        return value ? value.toString().toBigDecimal() : 0
    }
}

Note: A chunk size of 50 keeps each chunk well inside 15 seconds, since every Account runs its own query.

06
Daily closed-won digest
Post yesterday's wins to Slack every morning.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name slack, Base URL https://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. In Slack, create an app with the chat:write scope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret.
  3. Go to Setup → Customization → Classes and click New Class.
  4. Paste the SendSlackAlert code below into Source Code. The class name is its API name, so there is nothing else to name.
  5. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  6. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  7. Paste the ClosedWonDigest code below into Source Code.
  8. Tick Batch Mode, set Object API Name to opportunity and Chunk Size to 1000. A large chunk size means one message per day for most teams; each chunk posts its own message.
  9. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
  10. To repeat it every night, tick Run Daily, set Time (UTC) to 07:00 and save again.
Code
Class: SendSlackAlert
class SendSlackAlert {
    // Post a message to a Slack channel. Called by other classes:
    //   Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
    def post(params) {
        def res = Platform.httpPost('slack', '/chat.postMessage',
            [ channel: params.channel ?: '#sales', text: params.text ])
        if (res.status != 200 || !res.body.ok) {
            Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
            return false
        }
        return true
    }
}
Class: ClosedWonDigest
class ClosedWonDigest {
    def execute(batch) {
        def yesterday = day(-1)
        def wins = batch.findAll { it.stage == 'Closed Won' && it.close_date?.toString()?.take(10) == yesterday }
        if (!wins) return 0
        def total = wins.sum { num(it.amount) }
        def lines = wins.collect { '• ' + it.name + ' — ' + (it.amount ?: 0) }.join('\n')
        Platform.callClass('SendSlackAlert', 'post', [
            channel: '#sales',
            text: ':trophy: ' + wins.size() + ' deals won yesterday, total ' + total + '\n' + lines ])
        return wins.size()
    }
    // Today's date as yyyy-MM-dd (UTC), plus or minus some days
    private day(int offset) {
        return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
    }
    // Numbers come back as text; blank counts as 0
    private num(value) {
        return value ? value.toString().toBigDecimal() : 0
    }
}
07
Nightly duplicate sweep
Flag Accounts that share a name with another Account.
▾
Steps
  1. Go to Setup → Configuration → Object Manager → Account → Fields → New Field. Create a Checkbox field labelled Possible Duplicate; its API name becomes possible_duplicate__c.
  2. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  3. Paste the DuplicateAccountSweep code below into Source Code.
  4. Tick Batch Mode, set Object API Name to account and Chunk Size to 100.
  5. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
  6. To repeat it every night, tick Run Daily, set Time (UTC) to 04:00 and save again.
Code
class DuplicateAccountSweep {
    def execute(batch) {
        def flagged = 0
        batch.each { account ->
            if (!account.name || account.possible_duplicate__c) return
            def sameName = Platform.query('account', 'name', account.name)
            if (sameName.size() > 1) {
                Platform.update(account.hex_id, [ possible_duplicate__c: true ])
                flagged++
            }
        }
        Platform.log('flagged ' + flagged + ' possible duplicates')
        return flagged
    }
}

Note: Then build a list view on Account filtered by Possible Duplicate = true for someone to review.

08
Nightly price sync
Refresh every Product's price from your supplier's price list.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name priceApi, Base URL https://api.your-supplier.com, Auth Type API Key Header, Header Name X-Api-Key, and paste the key into the secret field. Save.
  2. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  3. Paste the ProductPriceSync code below into Source Code.
  4. Tick Batch Mode, set Object API Name to product and Chunk Size to 10. Keep it at 10: a run may make at most 10 web calls, one per product here.
  5. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
  6. To repeat it every night, tick Run Daily, set Time (UTC) to 02:00 and save again.
Code
class ProductPriceSync {
    def execute(batch) {
        def updated = 0
        batch.findAll { it.sku && it.active != false }.each { product ->
            def res = Platform.httpGet('priceApi', '/prices/' + product.sku)
            if (res.status != 200) {
                Platform.log('no price for ' + product.sku + ': HTTP ' + res.status)
                return
            }
            def price = res.body.price
            if (price != null && price.toString() != product.unit_price?.toString()) {
                Platform.update(product.hex_id, [ unit_price: price ])
                updated++
            }
        }
        return updated
    }
}

Note: Change /prices/ and res.body.price to match your supplier's API.

09
Auto-expire stale Quotes
Move Draft Quotes past their expiration date to Expired.
▾
Steps
  1. Add Expired to the Quote Status picklist: Setup → Configuration → Object Manager → Quote → Fields → Status → add the value.
  2. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  3. Paste the QuoteExpiryBatch code below into Source Code.
  4. Tick Batch Mode, set Object API Name to quote and Chunk Size to 200.
  5. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
  6. To repeat it every night, tick Run Daily, set Time (UTC) to 00:00 and save again.
Code
class QuoteExpiryBatch {
    def execute(batch) {
        def today = day(0)
        def expired = 0
        batch.each { quote ->
            def expires = quote.expiration_date?.toString()?.take(10)
            if (quote.status == 'Draft' && expires && expires < today) {
                Platform.update(quote.hex_id, [ status: 'Expired' ])
                expired++
            }
        }
        return expired
    }
    // Today's date as yyyy-MM-dd (UTC), plus or minus some days
    private day(int offset) {
        return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
    }
}
10
Bulk-update Leads from one source
Move every new Lead from an event into Nurturing in one run.
▾
Steps
  1. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  2. Paste the EventLeadNurture code below into Source Code.
  3. Tick Batch Mode, set Object API Name to lead and Chunk Size to 200.
  4. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
Code
class EventLeadNurture {
    def source = 'Event'          // change to the source you want
    def execute(batch) {
        def moved = 0
        batch.findAll { it.source == source && it.status == 'New' }.each { lead ->
            Platform.update(lead.hex_id, [ status: 'Nurturing' ])
            moved++
        }
        return moved
    }
}

🌐 Web Service Callouts — reach a third-party API through a Named Credential #

Every outside call goes through a Named Credential: the class names the credential, and SmartLite adds the address and the key. Only https, at most 10 calls per run, and the reply comes back as [status, body, headers]. The paths and reply fields below are typical; change them to match the service you sign up for.

11
Firmographic enrichment
Fill in industry, size and website for new Leads as they are created.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name enrichApi, Base URL https://api.your-enrichment.com, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. Go to Setup → Customization → Classes and click New Class.
  3. Paste the LeadEnrichment code below into Source Code. The class name is its API name, so there is nothing else to name.
  4. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  5. Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Lead and Trigger to On Create.
  6. Add an action of type Run Custom Class, choose LeadEnrichment, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class LeadEnrichment {
    def execute() {
        def todo = Platform.query('lead', 'status', 'New')
            .findAll { it.email && !it.industry && it.email.contains('@') }
            .take(10)                                   // at most 10 web calls per run
        todo.each { lead ->
            def domain = lead.email.substring(lead.email.indexOf('@') + 1)
            def res = Platform.httpGet('enrichApi', '/companies?domain=' + domain)
            if (res.status != 200) return
            def changes = [ industry: res.body.industry ?: 'Other' ]
            if (res.body.employees) changes.no_of_employees = res.body.employees
            if (res.body.website)   changes.website = res.body.website
            Platform.update(lead.hex_id, changes)
        }
        return todo.size()
    }
}

Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save. Here "done" means Industry is filled, so a lead the service doesn't know is set to Other.

12
Address validation
Check an Account's billing address when it is saved.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name addressApi, Base URL https://api.your-address-service.com, Auth Type API Key Header, Header Name X-Api-Key, and paste the key into the secret field. Save.
  2. Go to Setup → Configuration → Object Manager → Account → Fields → New Field. Create a Checkbox field labelled Address Verified; its API name becomes address_verified__c.
  3. Go to Setup → Customization → Classes and click New Class.
  4. Paste the AddressCheck code below into Source Code. The class name is its API name, so there is nothing else to name.
  5. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  6. Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Account and Trigger to On Create or Update.
  7. Add an action of type Run Custom Class, choose AddressCheck, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class AddressCheck {
    def execute() {
        def todo = Platform.query('account')
            .findAll { it.billing_zip && !it.address_verified__c }
            .take(10)
        todo.each { account ->
            def res = Platform.httpPost('addressApi', '/verify', [
                street: account.billing_street, city: account.billing_city,
                state: account.billing_state, zip: account.billing_zip,
                country: account.billing_country ])
            if (res.status != 200) return
            def changes = [ address_verified__c: res.body.valid == true ]
            if (res.body.valid && res.body.zip) changes.billing_zip = res.body.zip   // corrected ZIP
            Platform.update(account.hex_id, changes)
        }
        return todo.size()
    }
}

Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save. Clear Address Verified when someone edits the address (a Field Update action) so it is checked again.

13
Live shipping rates
A button on the Quote that fetches carrier rates for its shipping address.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name shippingApi, Base URL https://api.your-shipping.com, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. Go to Setup → Customization → Classes and click New Class.
  3. Paste the ShippingRates code below into Source Code. The class name is its API name, so there is nothing else to name.
  4. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  5. Go to Setup → Customization → SmartLite Components and click New Component. API Name shippingRates, Label Shipping Rates. Under Where it can go, tick Record page of and choose Quote. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  6. Go to Setup → Configuration → Object Manager → Quote → Page Layouts, open the layout, click Add Custom Component, choose Shipping Rates and save the layout. The button now shows on every Quote record.
Code
class ShippingRates {
    def getRates(params) {
        def quote = Platform.getRecord('quote', params.quoteId)
        if (!quote?.shipping_zip) return [ ok: false, message: 'Add a shipping ZIP first' ]
        def res = Platform.httpPost('shippingApi', '/rates', [
            toZip: quote.shipping_zip, toCountry: quote.shipping_country ?: 'US' ])
        if (res.status != 200) return [ ok: false, message: 'Rate lookup failed (' + res.status + ')' ]
        def options = res.body.rates.collect { [ carrier: it.carrier, price: it.amount, days: it.days ] }
        return [ ok: true, options: options.sort { a, b -> a.price <=> b.price } ]
    }
    // Second button: save the chosen price on the quote
    def choose(params) {
        Platform.update(params.quoteId, [ shipping_handling: params.price ])
        return [ ok: true ]
    }
}
shippingRates.html
<template>
  <button class="sl-primary" onclick={handleGetRates} disabled={busy}>Get shipping rates</button>
  <p class="message">{message}</p>
  <template for:each={options} for:item="o">
    <button key={o.carrier} class="option" onclick={handleChoose}>{o.label}</button>
  </template>
</template>
shippingRates.js
import getRates from '@class/ShippingRates.getRates';
import choose from '@class/ShippingRates.choose';

export default class ShippingRates extends SmartLiteComponent {
  options = [];
  message = '';
  busy = false;

  async handleGetRates() {
    this.busy = true;
    this.message = '';
    try {
      const r = await getRates({ quoteId: this.recordId });
      if (!r.ok) {
        this.message = r.message;
        this.options = [];
        return;
      }
      this.options = r.options.map(o => ({ ...o, label: o.carrier + ' — ' + o.price + ' (' + o.days + ' days)' }));
    } finally {
      this.busy = false;
    }
  }

  // In a for:each, the handler also gets the option that was clicked
  async handleChoose(event, option) {
    await choose({ quoteId: this.recordId, price: option.price });
    this.options = [];
    this.message = 'Saved ' + option.carrier + ' to the quote.';
  }
}
shippingRates.css
.option { display: block; margin-top: 6px; }
14
Slack alert on a big win
Post to Slack the moment a deal over $50,000 is won.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name slack, Base URL https://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. In Slack, create an app with the chat:write scope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret.
  3. Go to Setup → Configuration → Object Manager → Opportunity → Fields → New Field. Create a Checkbox field labelled Win Announced; its API name becomes win_announced__c.
  4. Go to Setup → Customization → Classes and click New Class.
  5. Paste the SendSlackAlert code below into Source Code. The class name is its API name, so there is nothing else to name.
  6. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  7. Go to Setup → Customization → Classes and click New Class.
  8. Paste the BigWinAlert code below into Source Code. The class name is its API name, so there is nothing else to name.
  9. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  10. Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Opportunity and Trigger to On Create or Update. Add the criteria Stage equals Closed Won and Amount greater than 50000.
  11. Add an action of type Run Custom Class, choose BigWinAlert, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
Class: SendSlackAlert
class SendSlackAlert {
    // Post a message to a Slack channel. Called by other classes:
    //   Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
    def post(params) {
        def res = Platform.httpPost('slack', '/chat.postMessage',
            [ channel: params.channel ?: '#sales', text: params.text ])
        if (res.status != 200 || !res.body.ok) {
            Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
            return false
        }
        return true
    }
}
Class: BigWinAlert
class BigWinAlert {
    def execute() {
        def wins = Platform.query('opportunity', 'stage', 'Closed Won')
            .findAll { num(it.amount) > 50000 && !it.win_announced__c }
            .take(10)
        wins.each { opp ->
            def ok = Platform.callClass('SendSlackAlert', 'post', [
                channel: '#wins',
                text: ':tada: ' + opp.name + ' just closed for ' + opp.amount + ' (owner: ' + opp.owner + ')' ])
            if (ok) Platform.update(opp.hex_id, [ win_announced__c: true ])
        }
        return wins.size()
    }
    // Numbers come back as text; blank counts as 0
    private num(value) {
        return value ? value.toString().toBigDecimal() : 0
    }
}

Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save.

15
SMS when a Case is resolved
Text the customer through an SMS provider when their Case is resolved.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name sms, Base URL https://api.your-sms-provider.com, Auth Type Basic Auth, and paste the key into the secret field. Save.
  2. Go to Setup → Configuration → Object Manager → Case → Fields → New Field. Create a Checkbox field labelled SMS Sent; its API name becomes sms_sent__c.
  3. Go to Setup → Customization → Classes and click New Class.
  4. Paste the CaseResolvedSms code below into Source Code. The class name is its API name, so there is nothing else to name.
  5. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  6. Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Case and Trigger to On Update. Add the criteria Status equals Resolved.
  7. Add an action of type Run Custom Class, choose CaseResolvedSms, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class CaseResolvedSms {
    def execute() {
        def resolved = Platform.query('case', 'status', 'Resolved')
            .findAll { !it.sms_sent__c && it.contact_id }
            .take(10)
        resolved.each { c ->
            def contact = Platform.getRecord('contact', c.contact_id)
            def phone = contact?.mobile ?: contact?.phone
            if (!phone) return
            def res = Platform.httpPost('sms', '/messages', [
                to: phone,
                body: 'Hi ' + (contact.first_name ?: '') + ', your case ' + c.name + ' (' + c.subject + ') is resolved.' ])
            if (res.status in [200, 201, 202]) Platform.update(c.hex_id, [ sms_sent__c: true ])
        }
        return resolved.size()
    }
}

Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save. For Basic Auth the secret is username:password.

16
Credit-risk check
A button on the Opportunity that checks the customer's credit before a big discount.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name creditApi, Base URL https://api.your-credit-bureau.com, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. Go to Setup → Customization → Classes and click New Class.
  3. Paste the CreditCheck code below into Source Code. The class name is its API name, so there is nothing else to name.
  4. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  5. Go to Setup → Customization → SmartLite Components and click New Component. API Name creditCheck, Label Credit Check. Under Where it can go, tick Record page of and choose Opportunity. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  6. Go to Setup → Configuration → Object Manager → Opportunity → Page Layouts, open the layout, click Add Custom Component, choose Credit Check and save the layout. The button now shows on every Opportunity record.
Code
class CreditCheck {
    def check(params) {
        def opp = Platform.getRecord('opportunity', params.opportunityId)
        if (!opp?.account_id) return [ ok: false, message: 'The opportunity has no account' ]
        def account = Platform.getRecord('account', opp.account_id)
        def res = Platform.httpGet('creditApi', '/score?company=' + account.name.replaceAll(' ', '+'))
        if (res.status != 200) return [ ok: false, message: 'Credit check failed (' + res.status + ')' ]
        def score = res.body.score as Integer
        def verdict = score >= 700 ? 'Low risk' : score >= 550 ? 'Medium risk: manager approval' : 'High risk: no discount'
        return [ ok: true, company: account.name, score: score, verdict: verdict ]
    }
}
creditCheck.html
<template>
  <button class="sl-primary" onclick={handleClick} disabled={busy}>Check credit</button>
  <p class="result">{resultText}</p>
</template>
creditCheck.js
import check from '@class/CreditCheck.check';

export default class CreditCheck extends SmartLiteComponent {
  result = null;
  busy = false;

  async handleClick() {
    this.busy = true;
    try {
      // this.recordId: the record this panel is on
      this.result = await check({ opportunityId: this.recordId });
    } catch (e) {
      this.result = { ok: false, message: e.message };
    } finally {
      this.busy = false;
    }
  }

  get resultText() {
    const r = this.result;
    if (!r) return '';
    return r.ok ? r.company + ': ' + r.score + ' — ' + r.verdict : r.message;
  }
}
17
Mailing-list sync
Add each new Contact to your email marketing list.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name mailingApi, Base URL https://api.your-email-platform.com, Auth Type API Key Header, Header Name X-Api-Key, and paste the key into the secret field. Save.
  2. Go to Setup → Configuration → Object Manager → Contact → Fields → New Field. Create a Checkbox field labelled On Mailing List; its API name becomes on_mailing_list__c.
  3. Go to Setup → Customization → Classes and click New Class.
  4. Paste the MailingListSync code below into Source Code. The class name is its API name, so there is nothing else to name.
  5. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  6. Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Contact and Trigger to On Create.
  7. Add an action of type Run Custom Class, choose MailingListSync, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class MailingListSync {
    def execute() {
        def todo = Platform.query('contact')
            .findAll { it.email && !it.on_mailing_list__c && !it.email_opt_out }
            .take(10)
        todo.each { c ->
            def res = Platform.httpPost('mailingApi', '/lists/main/members', [
                email: c.email, firstName: c.first_name, lastName: c.last_name ])
            if (res.status in [200, 201, 409]) {       // 409: already on the list
                Platform.update(c.hex_id, [ on_mailing_list__c: true ])
            }
        }
        return todo.size()
    }
}

Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save. Contacts who opted out of email are skipped.

18
Live exchange rates
Fetch today's exchange rates for any page or class that needs them.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name fxApi, Base URL https://api.your-fx-provider.com, Auth Type API Key Header, Header Name apikey, and paste the key into the secret field. Save.
  2. Go to Setup → Customization → Classes and click New Class.
  3. Paste the ExchangeRates code below into Source Code. The class name is its API name, so there is nothing else to name.
  4. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  5. Use it from another class: def rates = Platform.callClass('ExchangeRates', 'latest', [ base: 'USD' ]), then rates.EUR. Example 2 can use this instead of its fixed table.
Code
class ExchangeRates {
    def latest(params) {
        def base = params?.base ?: 'USD'
        def res = Platform.httpGet('fxApi', '/latest?base=' + base)
        if (res.status != 200) throw new IllegalStateException('Rates unavailable: HTTP ' + res.status)
        return res.body.rates.subMap(['USD', 'EUR', 'GBP', 'INR', 'AUD', 'CAD'])
    }
}
19
Email deliverability check
A button on the Lead that verifies its email address before you qualify it.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name emailCheckApi, Base URL https://api.your-email-verifier.com, Auth Type API Key Header, Header Name X-Api-Key, and paste the key into the secret field. Save.
  2. Go to Setup → Configuration → Object Manager → Lead → Fields → New Field. Create a Checkbox field labelled Email Verified; its API name becomes email_verified__c.
  3. Go to Setup → Customization → Classes and click New Class.
  4. Paste the EmailVerifier code below into Source Code. The class name is its API name, so there is nothing else to name.
  5. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  6. Go to Setup → Customization → SmartLite Components and click New Component. API Name verifyEmail, Label Verify Email. Under Where it can go, tick Record page of and choose Lead. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  7. Go to Setup → Configuration → Object Manager → Lead → Page Layouts, open the layout, click Add Custom Component, choose Verify Email and save the layout. The button now shows on every Lead record.
  8. Optional: in Setup → Configuration → Object Manager → Lead → Validation Rules, block Status = Qualified unless Email Verified is ticked.
Code
class EmailVerifier {
    def verify(params) {
        def lead = Platform.getRecord('lead', params.leadId)
        if (!lead?.email) return [ ok: false, message: 'The lead has no email' ]
        def res = Platform.httpGet('emailCheckApi', '/verify?email=' + lead.email)
        if (res.status != 200) return [ ok: false, message: 'Check failed (' + res.status + ')' ]
        def deliverable = res.body.result == 'deliverable'
        Platform.update(params.leadId, [ email_verified__c: deliverable ])
        return [ ok: true, deliverable: deliverable, result: res.body.result ]
    }
}
verifyEmail.html
<template>
  <button class="sl-primary" onclick={handleClick} disabled={busy}>Verify email</button>
  <p class="result">{resultText}</p>
</template>
verifyEmail.js
import verify from '@class/EmailVerifier.verify';

export default class VerifyEmail extends SmartLiteComponent {
  result = null;
  busy = false;

  async handleClick() {
    this.busy = true;
    try {
      // this.recordId: the record this panel is on
      this.result = await verify({ leadId: this.recordId });
    } catch (e) {
      this.result = { ok: false, message: e.message };
    } finally {
      this.busy = false;
    }
  }

  get resultText() {
    const r = this.result;
    if (!r) return '';
    if (!r.ok) return r.message;
    return r.deliverable ? '✓ Deliverable' : '✗ ' + r.result;
  }
}
20
Mirror a Case to another tool
Open a matching ticket in your engineering tracker for High and Critical Cases.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name ticketApi, Base URL https://api.your-tracker.com, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. Go to Setup → Configuration → Object Manager → Case → Fields → New Field. Create a Text field labelled External Ticket; its API name becomes external_ticket__c.
  3. Go to Setup → Customization → Classes and click New Class.
  4. Paste the CaseMirror code below into Source Code. The class name is its API name, so there is nothing else to name.
  5. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  6. Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Case and Trigger to On Create or Update. Add the criteria Priority equals High or Priority equals Critical.
  7. Add an action of type Run Custom Class, choose CaseMirror, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class CaseMirror {
    def execute() {
        def todo = Platform.query('case')
            .findAll { it.priority in ['High', 'Critical'] && !it.external_ticket__c && !(it.status in ['Resolved', 'Closed']) }
            .take(10)
        todo.each { c ->
            def res = Platform.httpPost('ticketApi', '/issues', [
                title: '[' + c.priority + '] ' + c.subject,
                description: c.description ?: '',
                labels: [ 'from-crm', c.priority.toLowerCase() ] ])
            if (res.status in [200, 201]) Platform.update(c.hex_id, [ external_ticket__c: res.body.id.toString() ])
        }
        return todo.size()
    }
}

Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save.

🖥️ Custom Screens — a standalone page your team reaches from the nav #

Each screen is one class that returns data plus one SmartLite Component that shows it (an app page), then a tab so people can find it. The component imports the class method, calls it when the screen opens or when the user submits, and its template shows what came back.

21
Cross-object search
Type part of an Account name and see every Contact under every match.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the AccountContactSearch code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Go to Setup → Customization → SmartLite Components and click New Component. API Name accountContactSearch, Label Account Search. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  5. Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Account Search from the SmartLite Components group under Page (its address is /c/accountContactSearch). Add the tab to your app in App Manager.
  6. Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class AccountContactSearch {
    def search(params) {
        def q = (params?.q ?: '').trim().toLowerCase()
        if (q.size() < 2) return [ results: [] ]
        def accounts = Platform.query('account').findAll { it.name?.toLowerCase()?.contains(q) }.take(20)
        def results = accounts.collect { a ->
            def contacts = Platform.query('contact', 'account_id', a.hex_id)
            [ id: a.hex_id, account: a.name, city: a.billing_city ?: '',
              contacts: contacts.collect { [ id: it.hex_id, name: it.first_name + ' ' + it.last_name, email: it.email ?: '' ] } ]
        }
        return [ results: results ]
    }
}
accountContactSearch.html
<template>
  <form onsubmit={handleSubmit}>
    <input value={q} oninput={handleInput} placeholder="Account name…">
    <button class="sl-primary" disabled={busy}>Search</button>
  </form>
  <template if:true={view}>
    <template for:each={view.results} for:item="r">
      <div key={r.id}>
        <strong>{r.account}</strong> {r.city}
        <ul>
          <template for:each={r.contacts} for:item="c">
            <li key={c.id}>{c.name} — {c.email}</li>
          </template>
        </ul>
      </div>
    </template>
    <template if:false={view.results}><p>No matching accounts.</p></template>
  </template>
</template>
accountContactSearch.js
import search from '@class/AccountContactSearch.search';

export default class AccountContactSearch extends SmartLiteComponent {
  q = '';
  view = null;          // nothing to show until the first search
  busy = false;

  handleInput(event) {
    this.q = event.target.value;
  }

  async handleSubmit() {
    this.busy = true;
    try {
      this.view = await search({ q: this.q });
    } finally {
      this.busy = false;
    }
  }
}
22
New-rep onboarding checklist
See at a glance how far a new rep has got: their first account, contact, deal and case.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the RepOnboarding code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Go to Setup → Customization → SmartLite Components and click New Component. API Name repOnboarding, Label Rep Onboarding. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  5. Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Rep Onboarding from the SmartLite Components group under Page (its address is /c/repOnboarding). Add the tab to your app in App Manager.
  6. Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class RepOnboarding {
    def checklist(params) {
        def rep = params?.username
        if (!rep) return [ steps: [] ]
        def steps = [
            [ label: 'Created an account',     done: Platform.query('account', 'owner', rep).size() > 0 ],
            [ label: 'Added a contact',        done: Platform.query('contact', 'owner', rep).size() > 0 ],
            [ label: 'Opened an opportunity',  done: Platform.query('opportunity', 'owner', rep).size() > 0 ],
            [ label: 'Won a deal',             done: Platform.query('opportunity', 'owner', rep).any { it.stage == 'Closed Won' } ],
            [ label: 'Handled a case',         done: Platform.query('case', 'owner', rep).size() > 0 ]
        ]
        steps.each { it.icon = it.done ? '✅' : '⬜' }
        return [ rep: rep, steps: steps, doneCount: steps.count { it.done }, total: steps.size() ]
    }
}
repOnboarding.html
<template>
  <form onsubmit={handleSubmit}>
    <input value={username} oninput={handleInput} placeholder="Username">
    <button class="sl-primary" disabled={busy}>Show</button>
  </form>
  <template if:true={view}>
    <h3>{view.rep}: {view.doneCount} of {view.total} done</h3>
    <template for:each={view.steps} for:item="s">
      <div key={s.label}>{s.icon} {s.label}</div>
    </template>
  </template>
</template>
repOnboarding.js
import checklist from '@class/RepOnboarding.checklist';

export default class RepOnboarding extends SmartLiteComponent {
  username = '';
  view = null;          // nothing to show until the first search
  busy = false;

  handleInput(event) {
    this.username = event.target.value;
  }

  async handleSubmit() {
    this.busy = true;
    try {
      this.view = await checklist({ username: this.username });
    } finally {
      this.busy = false;
    }
  }
}
23
Territory coverage view
Open Leads grouped by state, busiest first.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the LeadsByRegion code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Go to Setup → Customization → SmartLite Components and click New Component. API Name leadsByRegion, Label Leads by Region. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  5. Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Leads by Region from the SmartLite Components group under Page (its address is /c/leadsByRegion). Add the tab to your app in App Manager.
  6. Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class LeadsByRegion {
    def regions(params) {
        def open = Platform.query('lead').findAll { !(it.status in ['Converted', 'Unqualified']) }
        def rows = open.groupBy { it.state ?: 'Unknown' }.collect { state, leads ->
            [ region: state, count: leads.size(), hot: leads.count { it.rating == 'Hot' } ]
        }
        return [ regions: rows.sort { a, b -> b.count <=> a.count } ]
    }
}
leadsByRegion.html
<template>
  <template if:true={loading}><p class="sl-muted">Loading…</p></template>
  <template if:false={loading}>
    <table>
      <tr><th>Region</th><th>Open leads</th><th>Hot</th></tr>
      <template for:each={view.regions} for:item="r">
        <tr key={r.region}><td>{r.region}</td><td>{r.count}</td><td>{r.hot}</td></tr>
      </template>
    </table>
  </template>
</template>
leadsByRegion.js
import regions from '@class/LeadsByRegion.regions';

export default class LeadsByRegion extends SmartLiteComponent {
  view = { regions: [] };
  loading = true;

  async connectedCallback() {
    try {
      this.view = await regions({});
    } finally {
      this.loading = false;
    }
  }
}
24
Renewal radar
Every Active Contract ending in the next 30 days.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the RenewalRadar code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Go to Setup → Customization → SmartLite Components and click New Component. API Name renewalRadar, Label Renewal Radar. Under Where it can go, tick App page. In the Properties tab, add days (Number, default 30). Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  5. Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Renewal Radar from the SmartLite Components group under Page (its address is /c/renewalRadar). Add the tab to your app in App Manager.
  6. Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class RenewalRadar {
    def upcoming(params) {
        def days = (params?.days ?: 30) as Integer
        def today = day(0)
        def until = day(days)
        def due = Platform.query('contract', 'status', 'Active').findAll {
            def end = it.end_date?.toString()?.take(10)
            end && end >= today && end <= until
        }
        def rows = due.sort { it.end_date.toString() }.collect {
            [ id: it.hex_id, name: it.name, ends: it.end_date.toString().take(10), value: it.contract_value ?: 0,
              autoRenews: it.auto_renewal ? 'Yes' : 'No' ]
        }
        return [ contracts: rows, days: days ]
    }
    // Today's date as yyyy-MM-dd (UTC), plus or minus some days
    private day(int offset) {
        return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
    }
}
renewalRadar.html
<template>
  <template if:true={loading}><p class="sl-muted">Loading…</p></template>
  <template if:false={loading}>
    <h3>Ending in the next {view.days} days</h3>
    <table>
      <tr><th>Contract</th><th>Ends</th><th>Value</th><th>Auto-renews</th></tr>
      <template for:each={view.contracts} for:item="c">
        <tr key={c.id}><td>{c.name}</td><td>{c.ends}</td><td>{c.value}</td><td>{c.autoRenews}</td></tr>
      </template>
    </table>
    <template if:false={view.contracts}><p>Nothing ends soon.</p></template>
  </template>
</template>
renewalRadar.js
import upcoming from '@class/RenewalRadar.upcoming';

export default class RenewalRadar extends SmartLiteComponent {
  view = { contracts: [], days: 30 };
  loading = true;

  async connectedCallback() {
    try {
      this.view = await upcoming({ days: this.days });
    } finally {
      this.loading = false;
    }
  }
}
25
Discount approval calculator
Before submitting a Quote, see whether its discount will need sign-off and from whom.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the DiscountCalculator code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Go to Setup → Customization → SmartLite Components and click New Component. API Name discountCalculator, Label Discount Calculator. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  5. Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Discount Calculator from the SmartLite Components group under Page (its address is /c/discountCalculator). Add the tab to your app in App Manager.
  6. Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class DiscountCalculator {
    // Keep these in line with your Approval Process steps
    def rules = [
        [ upTo: 10,  approver: 'None — you can submit it' ],
        [ upTo: 20,  approver: 'Sales Manager' ],
        [ upTo: 30,  approver: 'Sales Director' ],
        [ upTo: 100, approver: 'VP Sales and Finance' ]
    ]
    def check(params) {
        def list = (params.listPrice ?: 0).toString().toBigDecimal()
        def pct  = (params.discountPct ?: 0).toString().toBigDecimal()
        def net  = (list * (100 - pct) / 100).setScale(2, 4)
        def rule = rules.find { pct <= it.upTo } ?: rules[-1]
        return [ net: net, saving: list - net, approver: rule.approver ]
    }
}
discountCalculator.html
<template>
  <form onsubmit={handleSubmit}>
    <label>List price <input type="number" name="listPrice" value={listPrice} oninput={handleInput}></label>
    <label>Discount % <input type="number" name="discountPct" value={discountPct} oninput={handleInput}></label>
    <button class="sl-primary" disabled={busy}>Check</button>
  </form>
  <template if:true={view}>
    <p>Net price <strong>{view.net}</strong> (saving {view.saving})</p>
    <p>Approval: <strong>{view.approver}</strong></p>
  </template>
</template>
discountCalculator.js
import check from '@class/DiscountCalculator.check';

export default class DiscountCalculator extends SmartLiteComponent {
  listPrice = '10000';
  discountPct = '15';
  view = null;
  busy = false;

  // Each input has the name of the field it fills in
  handleInput(event) {
    this[event.target.name] = event.target.value;
  }

  async handleSubmit() {
    this.busy = true;
    try {
      this.view = await check({ listPrice: this.listPrice, discountPct: this.discountPct });
    } finally {
      this.busy = false;
    }
  }
}
26
"My Day" screen
Your deals closing this week, your hot Leads and your open Cases on one page.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the MyDay code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Go to Setup → Customization → SmartLite Components and click New Component. API Name myDay, Label My Day. Under Where it can go, tick App page. In the Properties tab, add username (String). Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  5. Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick My Day from the SmartLite Components group under Page (its address is /c/myDay). Add the tab to your app in App Manager.
  6. Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class MyDay {
    def summary(params) {
        def me = params?.username
        def today = day(0)
        def weekEnd = day(7)
        def closing = Platform.query('opportunity', 'owner', me).findAll {
            def d = it.close_date?.toString()?.take(10)
            d && d >= today && d <= weekEnd && !(it.stage in ['Closed Won', 'Closed Lost'])
        }.collect { [ id: it.hex_id, name: it.name, stage: it.stage, closes: it.close_date.toString().take(10) ] }
        def hotLeads = Platform.query('lead', 'owner', me).findAll { it.rating == 'Hot' && it.status in ['New', 'Working'] }
            .collect { [ id: it.hex_id, name: it.first_name + ' ' + it.last_name, company: it.company ?: '' ] }
        def cases = Platform.query('case', 'owner', me).findAll { !(it.status in ['Resolved', 'Closed']) }
            .collect { [ id: it.hex_id, subject: it.subject, priority: it.priority ?: '' ] }
        return [ closing: closing, hotLeads: hotLeads, cases: cases ]
    }
    // Today's date as yyyy-MM-dd (UTC), plus or minus some days
    private day(int offset) {
        return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
    }
}
myDay.html
<template>
  <form onsubmit={handleSubmit}>
    <input value={username} oninput={handleInput} placeholder="Username">
    <button class="sl-primary" disabled={busy}>Show my day</button>
  </form>
  <template if:true={view}>
    <h3>Closing this week</h3>
    <template for:each={view.closing} for:item="o">
      <div key={o.id}>{o.name} — {o.stage}, {o.closes}</div>
    </template>
    <h3>Hot leads</h3>
    <template for:each={view.hotLeads} for:item="l">
      <div key={l.id}>{l.name} ({l.company})</div>
    </template>
    <h3>My open cases</h3>
    <template for:each={view.cases} for:item="c">
      <div key={c.id}>[{c.priority}] {c.subject}</div>
    </template>
  </template>
</template>
myDay.js
import summary from '@class/MyDay.summary';

export default class MyDay extends SmartLiteComponent {
  view = null;
  busy = false;

  // username is a property: a tab with Parameters username=priya opens it ready for Priya
  async connectedCallback() {
    if (this.username) await this.handleSubmit();
  }

  handleInput(event) {
    this.username = event.target.value;
  }

  async handleSubmit() {
    this.busy = true;
    try {
      this.view = await summary({ username: this.username });
    } finally {
      this.busy = false;
    }
  }
}

Note: A page can't yet tell who is signed in, so it asks for the username once and remembers it in the browser. Classes can't read Tasks yet (they live in Activities), so the page shows deals, leads and cases.

27
Partner lead-submission form
A form that checks a partner's lead and refuses duplicates before creating it.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the PartnerLeadForm code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Go to Setup → Customization → SmartLite Components and click New Component. API Name partnerLeadForm, Label Submit a Lead. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  5. Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Partner Lead Form from the SmartLite Components group under Page (its address is /c/partnerLeadForm). Add the tab to your app in App Manager.
  6. Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class PartnerLeadForm {
    def submit(params) {
        def errors = []
        if (!params.firstName) errors << 'First name is required'
        if (!params.lastName)  errors << 'Last name is required'
        if (!params.company)   errors << 'Company is required'
        if (!(params.email ==~ /[^@\s]+@[^@\s]+\.[^@\s]+/)) errors << 'Enter a valid email'
        if (errors) return [ ok: false, errors: errors ]
        def email = params.email.trim().toLowerCase()
        if (Platform.query('lead', 'email', email)) return [ ok: false, errors: [ 'We already have this lead' ] ]
        def id = Platform.insert('lead', [
            first_name: params.firstName, last_name: params.lastName, company: params.company,
            email: email, phone: params.phone, source: 'Partner', status: 'New' ])
        return [ ok: true, id: id ]
    }
}
partnerLeadForm.html
<template>
  <form onsubmit={handleSubmit}>
    <input name="firstName" placeholder="First name" value={form.firstName} oninput={handleInput}>
    <input name="lastName" placeholder="Last name" value={form.lastName} oninput={handleInput}>
    <input name="company" placeholder="Company" value={form.company} oninput={handleInput}>
    <input name="email" placeholder="Email" value={form.email} oninput={handleInput}>
    <input name="phone" placeholder="Phone" value={form.phone} oninput={handleInput}>
    <button class="sl-primary" disabled={busy}>Submit</button>
  </form>
  <p class="sl-success">{message}</p>
  <ul class="sl-error">
    <template for:each={errors} for:item="e">
      <li key={e}>{e}</li>
    </template>
  </ul>
</template>
partnerLeadForm.js
import submit from '@class/PartnerLeadForm.submit';

const EMPTY = { firstName: '', lastName: '', company: '', email: '', phone: '' };

export default class PartnerLeadForm extends SmartLiteComponent {
  form = { ...EMPTY };
  errors = [];
  message = '';
  busy = false;

  handleInput(event) {
    this.form = { ...this.form, [event.target.name]: event.target.value };
  }

  async handleSubmit() {
    this.busy = true;
    this.message = '';
    try {
      const r = await submit(this.form);
      this.errors = r.ok ? [] : r.errors;
      if (r.ok) {
        this.message = 'Thanks — lead created.';
        this.form = { ...EMPTY };
      }
    } catch (e) {
      // the platform refused the lead, e.g. a phone number in the wrong format
      this.errors = [e.message];
    } finally {
      this.busy = false;
    }
  }
}
28
Live stock lookup
Check a Product's stock in your warehouse system in real time.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name stockApi, Base URL https://api.your-warehouse.com, Auth Type API Key Header, Header Name X-Api-Key, and paste the key into the secret field. Save.
  2. Go to Setup → Customization → Classes and click New Class.
  3. Paste the StockLookup code below into Source Code. The class name is its API name, so there is nothing else to name.
  4. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  5. Go to Setup → Customization → SmartLite Components and click New Component. API Name stockLookup, Label Stock Lookup. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  6. Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Stock Lookup from the SmartLite Components group under Page (its address is /c/stockLookup). Add the tab to your app in App Manager.
  7. Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class StockLookup {
    def lookup(params) {
        def q = (params?.q ?: '').trim().toLowerCase()
        def products = Platform.query('product')
            .findAll { it.sku && (it.name?.toLowerCase()?.contains(q) || it.sku.toLowerCase() == q) }
            .take(10)                                        // one web call per product
        def rows = products.collect { p ->
            def res = Platform.httpGet('stockApi', '/stock/' + p.sku)
            [ name: p.name, sku: p.sku,
              onHand: res.status == 200 ? res.body.available : 'n/a',
              eta: res.status == 200 ? (res.body.nextDelivery ?: '') : '' ]
        }
        return [ products: rows ]
    }
}
stockLookup.html
<template>
  <form onsubmit={handleSubmit}>
    <input value={q} oninput={handleInput} placeholder="Product name or SKU">
    <button class="sl-primary" disabled={busy}>Check stock</button>
  </form>
  <template if:true={view}>
    <table>
      <tr><th>Product</th><th>SKU</th><th>Available</th><th>Next delivery</th></tr>
      <template for:each={view.products} for:item="p">
        <tr key={p.sku}><td>{p.name}</td><td>{p.sku}</td><td>{p.onHand}</td><td>{p.eta}</td></tr>
      </template>
    </table>
  </template>
</template>
stockLookup.js
import lookup from '@class/StockLookup.lookup';

export default class StockLookup extends SmartLiteComponent {
  q = '';
  view = null;          // nothing to show until the first search
  busy = false;

  handleInput(event) {
    this.q = event.target.value;
  }

  async handleSubmit() {
    this.busy = true;
    try {
      this.view = await lookup({ q: this.q });
    } finally {
      this.busy = false;
    }
  }
}

Note: If you use SmartLite's own Inventory package, stock is already on the Product; this example is for an outside warehouse system.

29
Executive summary screen
Open pipeline, win rate and case backlog on one page.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the ExecSummary code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Go to Setup → Customization → SmartLite Components and click New Component. API Name execSummary, Label Executive Summary. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  5. Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Executive Summary from the SmartLite Components group under Page (its address is /c/execSummary). Add the tab to your app in App Manager.
  6. Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class ExecSummary {
    def summary(params) {
        def opps = Platform.query('opportunity')
        def open = opps.findAll { !(it.stage in ['Closed Won', 'Closed Lost']) }
        def won  = opps.count { it.stage == 'Closed Won' }
        def lost = opps.count { it.stage == 'Closed Lost' }
        def cases = Platform.query('case').findAll { !(it.status in ['Resolved', 'Closed']) }
        return [
            openPipeline: open.sum { num(it.amount) } ?: 0,
            openDeals: open.size(),
            winRate: (won + lost) ? ((won * 100) / (won + lost)).setScale(0, 4) + '%' : '-',
            caseBacklog: cases.size(),
            urgentCases: cases.count { it.priority in ['High', 'Critical'] }
        ]
    }
    // Numbers come back as text; blank counts as 0
    private num(value) {
        return value ? value.toString().toBigDecimal() : 0
    }
}
execSummary.html
<template>
  <template if:true={loading}><p class="sl-muted">Loading…</p></template>
  <template if:false={loading}>
    <div class="tiles">
      <div><small>Open pipeline</small><h2>{view.openPipeline}</h2><small>{view.openDeals} deals</small></div>
      <div><small>Win rate</small><h2>{view.winRate}</h2></div>
      <div><small>Case backlog</small><h2>{view.caseBacklog}</h2><small>{view.urgentCases} urgent</small></div>
    </div>
  </template>
</template>
execSummary.js
import summary from '@class/ExecSummary.summary';

export default class ExecSummary extends SmartLiteComponent {
  view = { openPipeline: 0, openDeals: 0, winRate: '-', caseBacklog: 0, urgentCases: 0 };
  loading = true;

  async connectedCallback() {
    try {
      this.view = await summary({});
    } finally {
      this.loading = false;
    }
  }
}
execSummary.css
.tiles { display: flex; gap: 24px; flex-wrap: wrap; }
30
"Nearest Account" finder
For field reps: Accounts closest to a ZIP code.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the NearbyAccounts code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Go to Setup → Customization → SmartLite Components and click New Component. API Name nearbyAccounts, Label Nearby Accounts. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  5. Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Nearest Accounts from the SmartLite Components group under Page (its address is /c/nearbyAccounts). Add the tab to your app in App Manager.
  6. Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class NearbyAccounts {
    def near(params) {
        def zip = (params?.zip ?: '').trim()
        if (!(zip ==~ /\d{5}/)) return [ accounts: [], message: 'Enter a 5-digit ZIP code' ]
        def target = zip.toInteger()
        def rows = Platform.query('account')
            .findAll { it.billing_zip?.toString()?.take(5) ==~ /\d{5}/ }
            .collect { a -> [ id: a.hex_id, name: a.name, city: a.billing_city ?: '', zip: a.billing_zip.toString().take(5),
                              gap: (a.billing_zip.toString().take(5).toInteger() - target).abs() ] }
            .sort { it.gap }
            .take(15)
        return [ accounts: rows, message: '' ]
    }
}
nearbyAccounts.html
<template>
  <form onsubmit={handleSubmit}>
    <input value={zip} oninput={handleInput} placeholder="ZIP code">
    <button class="sl-primary" disabled={busy}>Find</button>
  </form>
  <template if:true={view}>
    <p class="sl-error">{view.message}</p>
    <template for:each={view.accounts} for:item="a">
      <div key={a.id}>{a.name} — {a.city} {a.zip}</div>
    </template>
  </template>
</template>
nearbyAccounts.js
import near from '@class/NearbyAccounts.near';

export default class NearbyAccounts extends SmartLiteComponent {
  zip = '';
  view = null;          // nothing to show until the first search
  busy = false;

  handleInput(event) {
    this.zip = event.target.value;
  }

  async handleSubmit() {
    this.busy = true;
    try {
      this.view = await near({ zip: this.zip });
    } finally {
      this.busy = false;
    }
  }
}

Note: Nearby ZIP codes are close in number, which is good enough for a quick list. For real distances, call a geocoding service through a Named Credential (example 12 shows the pattern).

🧩 Typed Model Classes — return proper objects with computed properties #

The main class comes first; one or more model classes follow it in the same source. Create them with def x = new Model(), never Model x = …. Every getSomething() becomes a something property the page can show, including computed ones.

31
Deal health score
A risk level for each open deal, worked out from several fields.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the DealHealthBoard code below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Show it with a SmartLite Component (an app page): import the method with import getDeals from '@class/DealHealthBoard.getDeals';, load it with this.view = await getDeals({}) in connectedCallback, and bind the getters in the template, e.g. {row.riskLevel} for getRiskLevel() (see step 7 and example 23).
Code
class DealHealthBoard {
    def getDeals(params) {
        def today = day(0)
        def deals = Platform.query('opportunity')
            .findAll { !(it.stage in ['Closed Won', 'Closed Lost']) }
            .collect { o ->
                def d = new DealHealth()
                d.setName(o.name)
                d.setStage(o.stage)
                d.setProbability(o.probability ? o.probability.toString().toBigDecimal() : 0)
                d.setOverdue(o.close_date && o.close_date.toString().take(10) < today)
                d.setHasNextStep(o.next_step ? true : false)
                d
            }
        return [ deals: deals.sort { a, b -> b.getRiskScore() <=> a.getRiskScore() } ]
    }
    // Today's date as yyyy-MM-dd (UTC), plus or minus some days
    private day(int offset) {
        return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
    }
}
class DealHealth {
    private String name
    private String stage
    private BigDecimal probability
    private boolean overdue
    private boolean hasNextStep
    String getName() { return name }
    void setName(String v) { name = v }
    String getStage() { return stage }
    void setStage(String v) { stage = v }
    void setProbability(BigDecimal v) { probability = v }
    void setOverdue(boolean v) { overdue = v }
    void setHasNextStep(boolean v) { hasNextStep = v }
    int getRiskScore() {
        def score = 0
        if (overdue) score += 50
        if (!hasNextStep) score += 20
        if (probability < 30) score += 30
        return score
    }
    String getRiskLevel() {
        def s = getRiskScore()
        return s >= 60 ? 'High' : s >= 30 ? 'Medium' : 'Low'
    }
}
32
Shipping quote model
Turn a carrier API's reply into clean carrier, price and delivery-date properties.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name shippingApi, Base URL https://api.your-shipping.com, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. Go to Setup → Customization → Classes and click New Class.
  3. Paste the ShippingQuotes code below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source.
  4. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  5. Show it with a SmartLite Component (an app page): import the method with import quote from '@class/ShippingQuotes.quote';, load it with this.view = await quote({ zip: '94105', weightKg: 2 }) in connectedCallback, and bind the getters in the template, e.g. {row.riskLevel} for getRiskLevel() (see step 7 and example 23).
Code
class ShippingQuotes {
    def quote(params) {
        def res = Platform.httpPost('shippingApi', '/rates', [ toZip: params.zip, weightKg: params.weightKg ?: 1 ])
        if (res.status != 200) return [ options: [], message: 'No rates (' + res.status + ')' ]
        def options = res.body.rates.collect { r ->
            def o = new ShippingOption()
            o.setCarrier(r.carrier)
            o.setPrice(r.amount.toString().toBigDecimal())
            o.setDays(r.days as Integer)
            o
        }
        return [ options: options.sort { a, b -> a.getPrice() <=> b.getPrice() }, message: '' ]
    }
}
class ShippingOption {
    private String carrier
    private BigDecimal price
    private Integer days
    String getCarrier() { return carrier }
    void setCarrier(String v) { carrier = v }
    BigDecimal getPrice() { return price }
    void setPrice(BigDecimal v) { price = v }
    Integer getDays() { return days }
    void setDays(Integer v) { days = v }
    String getEta() { return days == 1 ? 'Next day' : days + ' days' }
    String getLabel() { return carrier + ' — $' + price.setScale(2, 4) + ' (' + getEta() + ')' }
}
33
Lead summary
One object with a Lead's key facts and whether its company is already a customer.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the LeadSummaryService code below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Show it with a SmartLite Component (an app page): import the method with import summarise from '@class/LeadSummaryService.summarise';, load it with this.view = await summarise({ leadId: 'LEA…' }) in connectedCallback, and bind the getters in the template, e.g. {row.riskLevel} for getRiskLevel() (see step 7 and example 23).
Code
class LeadSummaryService {
    def summarise(params) {
        def lead = Platform.getRecord('lead', params.leadId)
        if (!lead) return null
        def s = new LeadSummary()
        s.setName(lead.first_name + ' ' + lead.last_name)
        s.setCompany(lead.company)
        s.setStatus(lead.status)
        s.setScore(lead.lead_score ? lead.lead_score.toString().toBigDecimal().intValue() : 0)
        def accounts = lead.company ? Platform.query('account', 'name', lead.company) : []
        s.setExistingCustomer(accounts.any { it.type == 'Customer' })
        return s
    }
}
class LeadSummary {
    private String name
    private String company
    private String status
    private int score
    private boolean existingCustomer
    String getName() { return name }
    void setName(String v) { name = v }
    String getCompany() { return company ?: '-' }
    void setCompany(String v) { company = v }
    String getStatus() { return status }
    void setStatus(String v) { status = v }
    int getScore() { return score }
    void setScore(int v) { score = v }
    boolean getExistingCustomer() { return existingCustomer }
    void setExistingCustomer(boolean v) { existingCustomer = v }
    String getHeadline() {
        return name + (existingCustomer ? ' (existing customer)' : '') + ' — ' + (score >= 70 ? 'hot' : score >= 40 ? 'warm' : 'cold')
    }
}

Note: Classes can't read Activities yet, so this summary doesn't include the latest call or email.

34
Account rollup
Each Account with its open pipeline worked out from its Opportunities.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the AccountRollups code below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Show it with a SmartLite Component (an app page): import the method with import getAccounts from '@class/AccountRollups.getAccounts';, load it with this.view = await getAccounts({}) in connectedCallback, and bind the getters in the template, e.g. {row.riskLevel} for getRiskLevel() (see step 7 and example 23).
Code
class AccountRollups {
    def getAccounts(params) {
        def byAccount = Platform.query('opportunity').findAll { it.account_id }.groupBy { it.account_id }
        def rows = Platform.query('account').take(50).collect { a ->
            def r = new AccountRollup()
            r.setName(a.name)
            r.setOpportunities(byAccount[a.hex_id] ?: [])
            r
        }
        return [ accounts: rows.sort { x, y -> y.getTotalOpenPipeline() <=> x.getTotalOpenPipeline() } ]
    }
}
class AccountRollup {
    private String name
    private List opportunities
    String getName() { return name }
    void setName(String v) { name = v }
    void setOpportunities(List v) { opportunities = v }
    BigDecimal getTotalOpenPipeline() {
        def open = opportunities.findAll { !(it.stage in ['Closed Won', 'Closed Lost']) }
        return open.sum { it.amount ? it.amount.toString().toBigDecimal() : 0 } ?: 0
    }
    int getOpenCount() { return opportunities.count { !(it.stage in ['Closed Won', 'Closed Lost']) } }
}

Note: All Opportunities are read once and grouped, rather than one query per Account.

35
Contract status
Turn Contract end dates into "days until expiry" and an "expiring soon" flag.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the ContractStatusList code below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Show it with a SmartLite Component (an app page): import the method with import getContracts from '@class/ContractStatusList.getContracts';, load it with this.view = await getContracts({}) in connectedCallback, and bind the getters in the template, e.g. {row.riskLevel} for getRiskLevel() (see step 7 and example 23).
Code
class ContractStatusList {
    def getContracts(params) {
        def rows = Platform.query('contract', 'status', 'Active').findAll { it.end_date }.collect { c ->
            def s = new ContractStatus()
            s.setName(c.name)
            s.setDaysUntilExpiry(daysUntil(c.end_date))
            s
        }
        return [ contracts: rows.sort { a, b -> a.getDaysUntilExpiry() <=> b.getDaysUntilExpiry() } ]
    }
    // Whole days from now until a yyyy-MM-dd date (negative when it has passed)
    private daysUntil(value) {
        if (!value) return null
        def p = value.toString().substring(0, 10).split('-')
        def end = new Date(p[0].toInteger() - 1900, p[1].toInteger() - 1, p[2].toInteger())
        return ((end.getTime() - new Date().getTime()) / 86400000).intValue() + 1
    }
}
class ContractStatus {
    private String name
    private Integer daysUntilExpiry
    String getName() { return name }
    void setName(String v) { name = v }
    Integer getDaysUntilExpiry() { return daysUntilExpiry }
    void setDaysUntilExpiry(Integer v) { daysUntilExpiry = v }
    boolean getIsExpiringSoon() { return daysUntilExpiry != null && daysUntilExpiry >= 0 && daysUntilExpiry <= 30 }
    String getLabel() {
        if (daysUntilExpiry < 0) return 'Expired ' + (-daysUntilExpiry) + ' days ago'
        if (daysUntilExpiry == 0) return 'Ends today'
        return 'Ends in ' + daysUntilExpiry + ' days'
    }
}
36
Rep scorecard
Each rep's quota, closed-won total and attainment percentage.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the RepScorecards code below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Show it with a SmartLite Component (an app page): import the method with import getScorecards from '@class/RepScorecards.getScorecards';, load it with this.view = await getScorecards({ quotas: { alice: 250000, bob: 180000 } }) in connectedCallback, and bind the getters in the template, e.g. {row.riskLevel} for getRiskLevel() (see step 7 and example 23).
Code
class RepScorecards {
    // params.quotas: { username: quota }, e.g. { alice: 250000, bob: 180000 }
    def getScorecards(params) {
        def quotas = params?.quotas ?: [:]
        def won = Platform.query('opportunity', 'stage', 'Closed Won').groupBy { it.owner ?: 'unassigned' }
        def cards = quotas.collect { rep, quota ->
            def c = new RepScorecard()
            c.setRep(rep)
            c.setQuota(quota.toString().toBigDecimal())
            c.setClosedWon((won[rep] ?: []).sum { it.amount ? it.amount.toString().toBigDecimal() : 0 } ?: 0)
            c
        }
        return [ reps: cards.sort { a, b -> b.getAttainmentPct() <=> a.getAttainmentPct() } ]
    }
}
class RepScorecard {
    private String rep
    private BigDecimal quota
    private BigDecimal closedWon
    String getRep() { return rep }
    void setRep(String v) { rep = v }
    BigDecimal getQuota() { return quota }
    void setQuota(BigDecimal v) { quota = v }
    BigDecimal getClosedWon() { return closedWon }
    void setClosedWon(BigDecimal v) { closedWon = v }
    BigDecimal getAttainmentPct() { return quota ? (closedWon * 100 / quota).setScale(1, 4) : 0 }
    String getBand() { def p = getAttainmentPct(); return p >= 100 ? 'On target' : p >= 70 ? 'Close' : 'Behind' }
}

Note: Quotas are passed in by the page; keep them in a custom object if you want admins to edit them.

37
Case escalation
Which open Cases have missed their resolution deadline.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the CaseEscalations code below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Show it with a SmartLite Component (an app page): import the method with import getCases from '@class/CaseEscalations.getCases';, load it with this.view = await getCases({}) in connectedCallback, and bind the getters in the template, e.g. {row.riskLevel} for getRiskLevel() (see step 7 and example 23).
Code
class CaseEscalations {
    def getCases(params) {
        def now = new Date().toInstant().toString().take(19)          // yyyy-MM-ddTHH:mm:ss (UTC)
        def rows = Platform.query('case').findAll { !(it.status in ['Resolved', 'Closed']) }.collect { c ->
            def e = new CaseEscalation()
            e.setSubject(c.subject)
            e.setPriority(c.priority)
            e.setDue(c.sla_resolution_due?.toString()?.take(19))
            e.setNow(now)
            e.setBreached(c.sla_breached == true)
            e
        }
        return [ cases: rows.findAll { it.getIsOverdue() } ]
    }
}
class CaseEscalation {
    private String subject
    private String priority
    private String due
    private String now
    private boolean breached
    String getSubject() { return subject }
    void setSubject(String v) { subject = v }
    String getPriority() { return priority ?: 'Medium' }
    void setPriority(String v) { priority = v }
    String getDue() { return due ? due.replace('T', ' ') : '-' }
    void setDue(String v) { due = v }
    void setNow(String v) { now = v }
    void setBreached(boolean v) { breached = v }
    boolean getIsOverdue() { return breached || (due && due < now) }
}

Note: SLA times are compared as text in UTC, the way they are stored.

38
Price-book line
Show Price Book entries with a display-ready price such as $12,500.00.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the PriceList code below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Show it with a SmartLite Component (an app page): import the method with import getLines from '@class/PriceList.getLines';, load it with this.view = await getLines({}) in connectedCallback, and bind the getters in the template, e.g. {row.riskLevel} for getRiskLevel() (see step 7 and example 23).
Code
class PriceList {
    def getLines(params) {
        def products = Platform.query('product').collectEntries { [ (it.hex_id): it.name ] }
        def lines = Platform.query('pricebook_entry').findAll { it.is_active != false }.collect { e ->
            def l = new PriceBookLine()
            l.setProduct(products[e.product_id] ?: e.name)
            l.setPrice(e.unit_price ? e.unit_price.toString().toBigDecimal() : 0)
            l.setCurrency(e.currency_iso_code ?: 'USD')
            l
        }
        return [ lines: lines.sort { it.getProduct() } ]
    }
}
class PriceBookLine {
    private String product
    private BigDecimal price
    private String currency
    String getProduct() { return product }
    void setProduct(String v) { product = v }
    void setPrice(BigDecimal v) { price = v }
    void setCurrency(String v) { currency = v }
    String getFormattedPrice() {
        def parts = price.setScale(2, 4).toPlainString().split('\\.')
        def whole = parts[0].replaceAll(/\B(?=(\d{3})+(?!\d))/, ',')
        def symbol = [ USD: '$', EUR: '€', GBP: '£', INR: '₹' ][currency] ?: currency + ' '
        return symbol + whole + '.' + parts[1]
    }
}
39
Campaign performance
Responses and cost for each Campaign, with cost per lead worked out.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the CampaignStats code below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Show it with a SmartLite Component (an app page): import the method with import getCampaigns from '@class/CampaignStats.getCampaigns';, load it with this.view = await getCampaigns({}) in connectedCallback, and bind the getters in the template, e.g. {row.riskLevel} for getRiskLevel() (see step 7 and example 23).
Code
class CampaignStats {
    def getCampaigns(params) {
        def rows = Platform.query('campaign').collect { c ->
            def p = new CampaignPerformance()
            p.setName(c.name)
            p.setLeads(c.num_responses ? c.num_responses.toString().toBigDecimal().intValue() : 0)
            p.setCost(c.actual_cost ? c.actual_cost.toString().toBigDecimal() : 0)
            p
        }
        return [ campaigns: rows.sort { a, b -> (a.getCostPerLead() ?: 999999) <=> (b.getCostPerLead() ?: 999999) } ]
    }
}
class CampaignPerformance {
    private String name
    private int leads
    private BigDecimal cost
    String getName() { return name }
    void setName(String v) { name = v }
    int getLeads() { return leads }
    void setLeads(int v) { leads = v }
    BigDecimal getCost() { return cost }
    void setCost(BigDecimal v) { cost = v }
    BigDecimal getCostPerLead() { return leads ? (cost / leads).setScale(2, 4) : null }
    String getCostPerLeadText() { return getCostPerLead() == null ? 'no leads yet' : getCostPerLead().toPlainString() }
}

Note: Uses each Campaign's Responses count as its leads.

40
Territory coverage
Each region with its count of open Leads and who covers it.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the TerritoryCoverageList code below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Show it with a SmartLite Component (an app page): import the method with import getRegions from '@class/TerritoryCoverageList.getRegions';, load it with this.view = await getRegions({}) in connectedCallback, and bind the getters in the template, e.g. {row.riskLevel} for getRiskLevel() (see step 7 and example 23).
Code
class TerritoryCoverageList {
    def getRegions(params) {
        def open = Platform.query('lead').findAll { !(it.status in ['Converted', 'Unqualified']) }
        def rows = open.groupBy { it.state ?: 'Unknown' }.collect { region, leads ->
            def t = new TerritoryCoverage()
            t.setRegion(region)
            t.setLeads(leads)
            t
        }
        return [ regions: rows.sort { a, b -> b.getOpenLeadCount() <=> a.getOpenLeadCount() } ]
    }
}
class TerritoryCoverage {
    private String region
    private List leads
    String getRegion() { return region }
    void setRegion(String v) { region = v }
    void setLeads(List v) { leads = v }
    int getOpenLeadCount() { return leads.size() }
    String getOwners() { return leads.collect { it.owner ?: 'unassigned' }.unique().sort().join(', ') }
    boolean getIsUncovered() { return leads.every { !it.owner } }
}

🔗 Classes Calling Classes — compose small pieces into bigger flows #

Platform.callClass('Name', 'method', [ … ]) runs another saved, active class inside the same run: same limits, no extra daily quota, at most 5 calls deep. Save the called classes first.

41
Validate-then-notify chain
When an Order is activated, one class checks it and, if it passes, another tells the team.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name slack, Base URL https://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. In Slack, create an app with the chat:write scope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret.
  3. Go to Setup → Customization → Classes and click New Class.
  4. Paste the SendSlackAlert code below into Source Code. The class name is its API name, so there is nothing else to name.
  5. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  6. Go to Setup → Customization → Classes and click New Class.
  7. Paste the OrderValidator code below into Source Code. The class name is its API name, so there is nothing else to name.
  8. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  9. Go to Setup → Customization → Classes and click New Class.
  10. Paste the OrderActivated code below into Source Code. The class name is its API name, so there is nothing else to name.
  11. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  12. Go to Setup → Configuration → Object Manager → Order → Fields → New Field. Create a Checkbox field labelled Team Notified; its API name becomes team_notified__c.
  13. Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Order and Trigger to On Create or Update. Add the criteria Status equals Activated.
  14. Add an action of type Run Custom Class, choose OrderActivated, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
Class: SendSlackAlert
class SendSlackAlert {
    // Post a message to a Slack channel. Called by other classes:
    //   Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
    def post(params) {
        def res = Platform.httpPost('slack', '/chat.postMessage',
            [ channel: params.channel ?: '#sales', text: params.text ])
        if (res.status != 200 || !res.body.ok) {
            Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
            return false
        }
        return true
    }
}
Class: OrderValidator
class OrderValidator {
    // Returns a list of problems; empty means the order is fine
    def check(params) {
        def order = Platform.getRecord('order', params.orderId)
        def problems = []
        if (!order.account_id) problems << 'no account'
        if (!order.shipping_street || !order.shipping_city) problems << 'no shipping address'
        def items = Platform.query('order_item', 'order_id', params.orderId)
        if (!items) problems << 'no products'
        if (items.any { !it.quantity || it.quantity.toString().toBigDecimal() <= 0 }) problems << 'a line with zero quantity'
        return problems
    }
}
Class: OrderActivated
class OrderActivated {
    def execute() {
        def orders = Platform.query('order', 'status', 'Activated').findAll { !it.team_notified__c }.take(10)
        orders.each { o ->
            def problems = Platform.callClass('OrderValidator', 'check', [ orderId: o.hex_id ])
            def text = problems
                ? ':warning: Order ' + o.name + ' was activated but has ' + problems.join(', ')
                : ':package: Order ' + o.name + ' is ready to ship'
            Platform.callClass('SendSlackAlert', 'post', [ channel: '#fulfilment', text: text ])
            Platform.update(o.hex_id, [ team_notified__c: true ])
        }
        return orders.size()
    }
}

Note: Check the Order Status picklist for the exact name of your activated value.

42
Scheduled finance sync
Every night, send yesterday's won deals to your finance system.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name financeApi, Base URL https://api.your-finance-system.com, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. Go to Setup → Customization → Classes and click New Class.
  3. Paste the FinanceSync code below into Source Code. The class name is its API name, so there is nothing else to name.
  4. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  5. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  6. Paste the FinanceSyncBatch code below into Source Code.
  7. Tick Batch Mode, set Object API Name to opportunity and Chunk Size to 500.
  8. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
  9. To repeat it every night, tick Run Daily, set Time (UTC) to 05:00 and save again.
Code
Class: FinanceSync
class FinanceSync {
    // Sends one deal; returns true when the finance system accepted it
    def push(params) {
        def opp = Platform.getRecord('opportunity', params.opportunityId)
        def account = opp.account_id ? Platform.getRecord('account', opp.account_id) : null
        def res = Platform.httpPost('financeApi', '/invoices/draft', [
            externalId: params.opportunityId, customer: account?.name ?: opp.name,
            amount: opp.amount, closeDate: opp.close_date ])
        return res.status in [200, 201, 409]          // 409: already sent
    }
}
Class: FinanceSyncBatch
class FinanceSyncBatch {
    def execute(batch) {
        def yesterday = day(-1)
        def wins = batch.findAll { it.stage == 'Closed Won' && it.close_date?.toString()?.take(10) == yesterday }.take(10)
        def sent = wins.count { Platform.callClass('FinanceSync', 'push', [ opportunityId: it.hex_id ]) }
        Platform.log('sent ' + sent + ' of ' + wins.size() + ' deals to finance')
        return sent
    }
    // Today's date as yyyy-MM-dd (UTC), plus or minus some days
    private day(int offset) {
        return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
    }
}

Note: At most 10 deals per chunk are sent, because each is one web call. If you win more than that in a day, lower the chunk size so more chunks share the work.

43
Component button chain
A button on the Opportunity that checks the discount and, if it is large, alerts the sales manager in Slack.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name slack, Base URL https://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. In Slack, create an app with the chat:write scope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret.
  3. Go to Setup → Customization → Classes and click New Class.
  4. Paste the SendSlackAlert code below into Source Code. The class name is its API name, so there is nothing else to name.
  5. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  6. Go to Setup → Customization → Classes and click New Class.
  7. Paste the DiscountGuard code below into Source Code. The class name is its API name, so there is nothing else to name.
  8. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  9. Go to Setup → Customization → SmartLite Components and click New Component. API Name discountGuard, Label Discount Check. Under Where it can go, tick Record page of and choose Opportunity. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  10. Go to Setup → Configuration → Object Manager → Opportunity → Page Layouts, open the layout, click Add Custom Component, choose Discount Check and save the layout. The button now shows on every Opportunity record.
Code
Class: SendSlackAlert
class SendSlackAlert {
    // Post a message to a Slack channel. Called by other classes:
    //   Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
    def post(params) {
        def res = Platform.httpPost('slack', '/chat.postMessage',
            [ channel: params.channel ?: '#sales', text: params.text ])
        if (res.status != 200 || !res.body.ok) {
            Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
            return false
        }
        return true
    }
}
Class: DiscountGuard
class DiscountGuard {
    def review(params) {
        def opp = Platform.getRecord('opportunity', params.opportunityId)
        def lines = Platform.query('opportunity_line_item', 'opportunity_id', params.opportunityId)
        if (!lines) return [ ok: false, message: 'Add products first' ]
        def biggest = lines.collect { it.discount ? it.discount.toString().toBigDecimal() : 0 }.max()
        if (biggest <= 15) return [ ok: true, message: 'Discounts are within policy (max ' + biggest + '%)' ]
        Platform.callClass('SendSlackAlert', 'post', [
            channel: '#sales-managers',
            text: ':eyes: ' + opp.name + ' has a ' + biggest + '% discount and needs a review' ])
        return [ ok: true, message: 'A ' + biggest + '% discount needs approval: your manager has been told' ]
    }
}
discountGuard.html
<template>
  <button class="sl-primary" onclick={handleClick} disabled={busy}>Check discount</button>
  <p class="result">{resultText}</p>
</template>
discountGuard.js
import review from '@class/DiscountGuard.review';

export default class DiscountGuard extends SmartLiteComponent {
  result = null;
  busy = false;

  async handleClick() {
    this.busy = true;
    try {
      // this.recordId: the record this panel is on
      this.result = await review({ opportunityId: this.recordId });
    } catch (e) {
      this.result = { ok: false, message: e.message };
    } finally {
      this.busy = false;
    }
  }

  get resultText() {
    const r = this.result;
    if (!r) return '';
    return r.message;
  }
}
44
Volume-aware case assignment
Give each new unassigned Case to the agent with the fewest open Cases.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the CaseLoadBalancer code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Go to Setup → Customization → Classes and click New Class.
  5. Paste the NewCaseAssigner code below into Source Code. The class name is its API name, so there is nothing else to name.
  6. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  7. Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Case and Trigger to On Create.
  8. Add an action of type Run Custom Class, choose NewCaseAssigner, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
Class: CaseLoadBalancer
class CaseLoadBalancer {
    def agents = [ 'alice', 'bob', 'carol' ]      // usernames of your support agents
    // Returns the agent with the fewest open cases right now
    def pickAgent(params) {
        def open = Platform.query('case').findAll { !(it.status in ['Resolved', 'Closed']) }
        def load = agents.collectEntries { a -> [ (a): open.count { it.owner == a } ] }
        return agents.min { load[it] }
    }
}
Class: NewCaseAssigner
class NewCaseAssigner {
    def execute() {
        def unassigned = Platform.query('case', 'status', 'New').findAll { !it.owner }
        unassigned.each { c ->
            def agent = Platform.callClass('CaseLoadBalancer', 'pickAgent', [:])
            Platform.update(c.hex_id, [ owner: agent ])
        }
        return unassigned.size()
    }
}

Note: If you only need round-robin, Case assignment rules already do that without code; this is for sharing by current load.

45
Personalised discount step
In a Journey, work out a discount for the customer before the quote email goes out.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the QuoteDiscount code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Go to Setup → Automation → Workflow Rules → New Rule. Set Object to Quote, Trigger to On Create.
  5. Add a Run Custom Class step with QuoteDiscount first, then your email step after it, so the email shows the new discount. Save the rule as active.
Code
class QuoteDiscount {
    def execute() {
        def drafts = Platform.query('quote', 'status', 'Draft').findAll { !it.discount && it.account_id }
        drafts.each { q ->
            def account = Platform.getRecord('account', q.account_id)
            def pct = 0
            if (account?.type == 'Customer') pct += 5           // loyalty
            if (account?.rating == 'Hot')    pct += 3
            if (num(account?.annual_revenue) > 10000000) pct += 2
            if (pct) Platform.update(q.hex_id, [ discount: pct ])
        }
        return drafts.size()
    }
    // Numbers come back as text; blank counts as 0
    private num(value) {
        return value ? value.toString().toBigDecimal() : 0
    }
}

Note: Change the rules to your own pricing policy. Large discounts should still go through an Approval Process.

46
One enrichment class, two callers
A nightly batch and an on-demand page both use the same enrichment class.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name enrichApi, Base URL https://api.your-enrichment.com, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. Go to Setup → Customization → Classes and click New Class.
  3. Paste the AccountEnricher code below into Source Code. The class name is its API name, so there is nothing else to name.
  4. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  5. Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
  6. Paste the AccountEnrichBatch code below into Source Code.
  7. Tick Batch Mode, set Object API Name to account and Chunk Size to 10. 10 per chunk, since each account is one web call.
  8. Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
  9. To repeat it every night, tick Run Daily, set Time (UTC) to 03:00 and save again.
  10. For the on-demand side, import it in a SmartLite Component: import enrich from '@class/AccountEnricher.enrich';, then await enrich({ accountId: id }). Example 50 does this.
Code
Class: AccountEnricher
class AccountEnricher {
    // Fills industry, employees and revenue from the account's website. Returns what changed.
    def enrich(params) {
        def account = Platform.getRecord('account', params.accountId)
        if (!account?.website) return [:]
        def domain = account.website.replaceAll('^https?://', '').replaceAll('/.*$', '')
        def res = Platform.httpGet('enrichApi', '/companies?domain=' + domain)
        if (res.status != 200) return [:]
        def changes = [:]
        if (res.body.industry && !account.industry)       changes.industry = res.body.industry
        if (res.body.employees && !account.employees)     changes.employees = res.body.employees
        if (res.body.revenue && !account.annual_revenue)  changes.annual_revenue = res.body.revenue
        if (changes) Platform.update(params.accountId, changes)
        return changes
    }
}
Class: AccountEnrichBatch
class AccountEnrichBatch {
    def execute(batch) {
        def todo = batch.findAll { it.website && !it.industry }
        def changed = todo.count { Platform.callClass('AccountEnricher', 'enrich', [ accountId: it.hex_id ]) }
        Platform.log('enriched ' + changed + ' of ' + todo.size())
        return changed
    }
}
47
Three-class trigger chain
On an Opportunity save: score it, tell the owner, and send it to another system.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name hooks, Base URL https://hooks.your-system.com, Auth Type API Key Header, Header Name X-Api-Key, and paste the key into the secret field. Save.
  2. Go to Setup → Customization → Named Credentials and click New: API Name slack, Base URL https://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save.
  3. In Slack, create an app with the chat:write scope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret.
  4. Go to Setup → Customization → Classes and click New Class.
  5. Paste the SendSlackAlert code below into Source Code. The class name is its API name, so there is nothing else to name.
  6. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  7. Go to Setup → Customization → Classes and click New Class.
  8. Paste the OpportunityScorer code below into Source Code. The class name is its API name, so there is nothing else to name.
  9. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  10. Go to Setup → Customization → Classes and click New Class.
  11. Paste the OpportunityWebhook code below into Source Code. The class name is its API name, so there is nothing else to name.
  12. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  13. Go to Setup → Customization → Classes and click New Class.
  14. Paste the OpportunityNotifier code below into Source Code. The class name is its API name, so there is nothing else to name.
  15. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  16. Go to Setup → Configuration → Object Manager → Opportunity → Fields → New Field. Create a Number field labelled Score; its API name becomes score__c.
  17. Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Opportunity and Trigger to On Create or Update.
  18. Add an action of type Run Custom Class, choose OpportunityNotifier, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
Class: SendSlackAlert
class SendSlackAlert {
    // Post a message to a Slack channel. Called by other classes:
    //   Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
    def post(params) {
        def res = Platform.httpPost('slack', '/chat.postMessage',
            [ channel: params.channel ?: '#sales', text: params.text ])
        if (res.status != 200 || !res.body.ok) {
            Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
            return false
        }
        return true
    }
}
Class: OpportunityScorer
class OpportunityScorer {
    def score(params) {
        def o = Platform.getRecord('opportunity', params.opportunityId)
        def s = 0
        if (o.amount && o.amount.toString().toBigDecimal() > 25000) s += 30
        if (o.contact_id) s += 20
        if (o.next_step) s += 20
        s += ((o.probability ?: 0).toString().toBigDecimal() * 0.3).intValue()
        return s
    }
}
Class: OpportunityWebhook
class OpportunityWebhook {
    def send(params) {
        def res = Platform.httpPost('hooks', '/crm/opportunity', params)
        return res.status in [200, 201, 202, 204]
    }
}
Class: OpportunityNotifier
class OpportunityNotifier {
    // The workflow runs this class; it calls the other three
    def execute() {
        def open = Platform.query('opportunity').findAll { !(it.stage in ['Closed Won', 'Closed Lost']) }.take(5)
        open.each { o ->
            def score = Platform.callClass('OpportunityScorer', 'score', [ opportunityId: o.hex_id ])
            if (score == (o.score__c ?: -1).toString().toBigDecimal().intValue()) return   // nothing changed
            Platform.update(o.hex_id, [ score__c: score ])
            if (score >= 70) {
                Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: o.name + ' now scores ' + score ])
            }
            Platform.callClass('OpportunityWebhook', 'send', [ id: o.hex_id, name: o.name, stage: o.stage, score: score ])
        }
    }
}

Note: Up to 5 opportunities per run, because each can make 2 web calls and a run allows 10. Each depth of callClass counts toward the limit of 5.

48
Reusable alert class
One SendSlackAlert class, called from rules on five different objects.
▾
Steps
  1. Go to Setup → Customization → Named Credentials and click New: API Name slack, Base URL https://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save.
  2. In Slack, create an app with the chat:write scope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret.
  3. Go to Setup → Customization → Classes and click New Class.
  4. Paste the SendSlackAlert code below into Source Code. The class name is its API name, so there is nothing else to name.
  5. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  6. Go to Setup → Customization → Classes and click New Class.
  7. Paste the CriticalCaseAlert code below into Source Code. The class name is its API name, so there is nothing else to name.
  8. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  9. Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Case and Trigger to On Create. Add the criteria Priority equals Critical.
  10. Add an action of type Run Custom Class, choose CriticalCaseAlert, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
  11. Repeat with a small class for each other rule (big win, lost deal, contract terminated, new partner lead): each is a few lines that build the message and call SendSlackAlert. Change the Slack channel or token once, in one place.
Code
Class: SendSlackAlert
class SendSlackAlert {
    // Post a message to a Slack channel. Called by other classes:
    //   Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
    def post(params) {
        def res = Platform.httpPost('slack', '/chat.postMessage',
            [ channel: params.channel ?: '#sales', text: params.text ])
        if (res.status != 200 || !res.body.ok) {
            Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
            return false
        }
        return true
    }
}
Class: CriticalCaseAlert
class CriticalCaseAlert {
    def execute() {
        def critical = Platform.query('case', 'priority', 'Critical')
            .findAll { it.status == 'New' && !it.owner }
            .take(10)
        critical.each { c ->
            Platform.callClass('SendSlackAlert', 'post', [
                channel: '#support-urgent', text: ':rotating_light: Critical case ' + c.name + ': ' + c.subject ])
        }
        return critical.size()
    }
}

Note: A Workflow Rule can't pass arguments to a class, which is why each rule gets its own tiny class instead of calling SendSlackAlert directly. Unassigned New cases are re-announced on later saves until someone takes them.

49
Helper that calls itself
Walk Account → its Opportunities → their line items with one helper that calls itself for each level.
▾
Steps
  1. Go to Setup → Customization → Classes and click New Class.
  2. Paste the AccountTree code below into Source Code. The class name is its API name, so there is nothing else to name.
  3. Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
  4. Call it from a SmartLite Component: import build from '@class/AccountTree.build';, then this.tree = await build({ accountId: this.recordId }). The result is nested: each node has a label and children; show each level with a nested for:each, or with a child component that shows one node and uses itself for the children.
Code
class AccountTree {
    // Each level: which object holds the children, and which field points at the parent
    def levels = [
        [ object: 'opportunity',           parentField: 'account_id',     label: { it.name + ' (' + (it.stage ?: '') + ')' } ],
        [ object: 'opportunity_line_item', parentField: 'opportunity_id', label: { it.name + ' × ' + it.quantity } ]
    ]
    def build(params) {
        def account = Platform.getRecord('account', params.accountId)
        return [ label: account.name, children: walk(params.accountId, 0) ]
    }
    // Calls itself once per level until there are no more levels
    private walk(parentId, int depth) {
        if (depth >= levels.size()) return []
        def level = levels[depth]
        return Platform.query(level.object, level.parentField, parentId).collect { row ->
            [ label: level.label.call(row), children: walk(row.hex_id, depth + 1) ]
        }
    }
}

Note: Add a third level (for example quote line items) by adding one entry to levels; the helper doesn't change.

50
Search-then-enrich screen
Search Accounts on a page, then enrich any result with one click, reusing two classes from above.
▾
Steps
  1. Build examples 21 (AccountContactSearch) and 46 (AccountEnricher) first.
  2. Go to Setup → Customization → SmartLite Components and click New Component. API Name accountEnrich, Label Account Enrichment. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save.
  3. Add a tab for Account Enrichment (/c/accountEnrich) in Setup → Configuration → Tab Manager, as in example 21.
  4. Search for a company, then click Enrich on a row. The search class finds it, the enrichment class calls the web service and saves what it learns.
Code
class AccountSearch {
    // Like example 21 but returns the account IDs, so the page can enrich a row
    def search(params) {
        def q = (params?.q ?: '').trim().toLowerCase()
        if (q.size() < 2) return []
        return Platform.query('account').findAll { it.name?.toLowerCase()?.contains(q) }.take(20).collect {
            [ id: it.hex_id, name: it.name, website: it.website ?: '', industry: it.industry ?: '' ]
        }
    }
}
accountEnrich.html
<template>
  <form onsubmit={handleSearch}>
    <input value={q} oninput={handleInput} placeholder="Company">
    <button class="sl-primary">Search</button>
  </form>
  <table>
    <template for:each={rows} for:item="a">
      <tr key={a.id}>
        <td>{a.name}</td>
        <td>{a.website}</td>
        <td>{a.industryText}</td>
        <td><button onclick={handleEnrich} disabled={a.locked}>{a.buttonText}</button></td>
      </tr>
    </template>
  </table>
</template>
accountEnrich.js
import search from '@class/AccountSearch.search';
import enrich from '@class/AccountEnricher.enrich';

export default class AccountEnrich extends SmartLiteComponent {
  q = '';
  rows = [];

  handleInput(event) {
    this.q = event.target.value;
  }

  async handleSearch() {
    const accounts = await search({ q: this.q });
    this.rows = accounts.map(a => ({
      ...a,
      industryText: a.industry || '—',
      buttonText: 'Enrich',
      locked: !a.website,          // nothing to look up without a website
    }));
  }

  async handleEnrich(event, account) {
    this.update(account.id, { locked: true });
    const changes = await enrich({ accountId: account.id });
    this.update(account.id, {
      industryText: changes.industry || account.industryText,
      buttonText: Object.keys(changes).length ? 'Updated' : 'Nothing new',
    });
  }

  // Replace one row with a changed copy: assigning the list redraws that row
  update(id, patch) {
    this.rows = this.rows.map(r => (r.id === id ? { ...r, ...patch } : r));
  }
}

Note: Save AccountSearch under Classes like any other class. Enrichment runs only when someone clicks, so the page never uses web calls just by being opened.

Day-to-day: admins maintaining a script #

Update the Class when logic needs to changeEdit the saved script under Developer Console → Saved Scripts — every Page or Workflow Rule calling it picks up the change immediately.
Check on a daily-scheduled batch jobThe scripts list shows a "Daily HH:00 UTC" badge on anything auto-running; open Recent Batch Jobs to see the most recent run's status and how many records it processed.
A scheduled run didn't seem to fireCheck whether the previous run was still PENDING/RUNNING at the scheduled hour — a still-running prior job is skipped on purpose rather than stacked, so it'll simply pick up at tomorrow's scheduled time instead.
Watch the usage limitIf a page feels slow or hits its limit, check whether it's calling the Class too often (like on every keystroke) instead of on an explicit action.

What to try next

Look at the Developer Console trail to get comfortable with the query and script tools this trail relies on.