"We need one screen that searches across objects โ not just one record's related list."
"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 Custom Pages (standalone screens 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.
Two building blocks, one governed sandbox
Custom Classes and Custom Pages are separate pieces that plug together โ you'll usually build a Class first, then a Page (or a Workflow Rule action) that calls it.
PARAMS. A Class run from a Workflow Rule or batch job has no PARAMS โ those are two different ways of triggering the same script.PARAMS, since there's no user typing anything in.| Where a Custom Class can run | Gets PARAMS? | Triggered by |
|---|---|---|
| Custom Page (on demand) | Yes | A user action inside the page, e.g. clicking Search |
| Custom Component, on a record's sidebar or main body (on demand) | Yes | The component's own script calling SmartLiteComponent.callClass(...) โ see the Component Library trail |
| Workflow Rule / Journey Builder โ "Run Custom Class" | No | Record created/updated (after save) |
| Batch mode (from Developer Console) | No | Manually started, processes records in chunks |
PARAMS exists before reading from it) and you can reuse the exact same script from a Custom Page and a Workflow Rule, instead of writing it twice.๐ฑ 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).
CRM.insert('lead', [first_name: 'Priya', last_name: 'Nair', email: 'priya@x.com']) โ returns the new record's ID.CRM.update(leadId, [status: 'Contacted']) โ only the fields you pass are changed; everything else is left alone.CRM.query('lead', 'status', 'New') โ filtered by one field, or CRM.getRecord('lead', leadId) for a single record by ID.return CRM.insert('lead', [first_name: 'Test', last_name: 'Lead', email: 'test@example.com']), save, and click Run. You'll get back the new Lead's ID immediately โ that's the whole loop.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.
findContactsByAccountName. In the source editor, write a script that reads PARAMS.q, finds matching Accounts with CRM.query('account'), then finds each one's Contacts with CRM.query('contact', 'account_id', accountHexId), and returns the combined list โ the script's last expression becomes its result.accountContactSearch. Add a text input, a Search button, and a results list; in the button's click handler, call SmartLiteComponent.callClass('findContactsByAccountName', { q: /* the input's value */ }) and render whatever comes back./custom-pages/accountContactSearch. Assign the new tab to whichever app your team lives in.findContactsByAccountName. It'll now also fire automatically whenever a matching Account is saved โ remember, in that context the script gets no PARAMS, so guard for that if you're reusing the same script.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.
lead and Chunk Size to 200 (the default). Instead of PARAMS, your script now works against BATCH โ a list of the current chunk's records: BATCH.each { record -> if (!record.lead_source) { CRM.update(record.hex_id, [lead_source: 'Unknown']) } }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.
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.
def, not a specific type โ def name = 'Priya', def count = 0, def rows = []. This isn't just simpler: the safety sandbox only allows calling methods on a variable when its type isn't pinned down, so def is what makes some of the patterns below (like your own model classes) actually compile.class 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.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).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 โ Classes. From a Custom Page: SmartLiteComponent.callClass('LeadService', 'getSummary', { leadId: 'a1b2c3' }).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.
defdef b = new Breed() โ not Breed b = new Breed(). As above, this is what lets the safety sandbox allow it.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.
shippingApi), the base URL, and how to authenticate (None / API Key Header / Bearer Token / Basic).def res = Platform.httpGet('shippingApi', '/rates?zip=94105') โ also httpPost/httpPut/httpDelete, all returning [status, body, headers]. The auth header is injected for you.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 Custom Page doesn't need hand-written DOM code to show a list or a value โ its built-in template engine uses the same directive names as a Salesforce Lightning Web Component template, so if you already know LWC, you already know this.
<div id="result">Loading...</div>
<template id="tpl">
<table>
<template for:each={breeds} for:item="breed">
<tr key={breed.breedName}><td>{breed.breedName}</td><td>{breed.subBreedsText}</td></tr>
</template>
</table>
<template if:false={breeds}><p>No breeds found.</p></template>
</template>
<script>
SmartLiteComponent.renderClass('DogBreeds', 'getBreeds', {}, '#result', '#tpl');
</script>
Day-to-day: regular users (non-technical)
50 things you can build
A reference gallery, not a to-do list โ skim for the pattern closest to your own need and adapt it. Grouped by the five building blocks this trail covers.
๐๏ธ Batch Classes โ process thousands of records unattended
lead_source to "Unknown" in one nightly run.๐ Web Service Callouts โ reach a third-party API through a Named Credential
๐ฅ๏ธ Custom Screens โ a standalone page your team reaches from the nav
๐งฌ Typed Model Classes โ a class with real getters and setters
getRiskLevel() derived from several underlying fields.carrier/price/eta properties.getTotalOpenPipeline() across an Account's Opportunities.getDaysUntilExpiry() / getIsExpiringSoon().getAttainmentPct() together.getIsOverdue() derived from SLA fields.getFormattedPrice().getCostPerLead().getOpenLeadCount().๐ Automation & Class-Calling โ one class calling another
Day-to-day: admins maintaining a script
Test what you learned
CRM.update(leadId, [status: 'Contacted']) do to the other fields on that Lead?Breed b = new Breed() (a specific type) instead of def b = new Breed(), then call b.setBreedName(...). What happens?def vs a specific type makes no differencePlatform.httpGet('credentialApiName', '/path'), referencing a Named Credential by API name