The scripting sandbox
When a rule or a workflow needs logic that its declarative options cannot express, you drop into a script body: a short piece of Python that runs in a locked-down sandbox. Business-rule script bodies, workflow script steps, and the SDK all share one sandbox and one helper set, so a script you write in one place behaves the same in the others. This guide covers the context you get, the helpers you can call, what is blocked, and what you set to return a result. For where these scripts attach, read Business rules and Workflows.
Goal
Write a script body that reads the record, does its work through the provided helpers, and returns its result the way the sandbox expects, without reaching for anything the sandbox blocks.
Prerequisites
- The write capability for the surface the script lives in:
platform.rules.writefor a business rule,platform.workflows.writefor a workflow step. - Familiarity with the record you are scripting against (its fields and its lifecycle states).
The context you get
When your script runs, these variables are already in scope. You do not import or construct them.
record. The current record as a dict. Read a field withrecord.get('field').previous. The record’s prior state on an update, orNoneon an insert. Compare againstrecordto detect what changed.result. The output container. You set values on it to return data.variables. A dict shared across scripts in the same execution chain.results. Outputs from earlier scripts in the chain.db,notify,api,log,approval,ai,workflow. The helper objects, below.
The helpers
db
Read and write records. Reads are always allowed; writes require the script to run in a write-enabled context.
db.query(table, filters=None, limit=100, order_by=None, select_fields=None). Returns a list of rows. Always check the list before indexing it.db.get(table, record_id, select_fields=None). Returns one row orNone.db.count(table, filters=None). Returns an integer.db.aggregate(table, field, operation='SUM', filters=None). RunsSUM,AVG,MIN,MAX, orCOUNT.db.update(table, record_id, updates). Writes changed fields to a record.db.insert(table, data). Creates a record.
notify
notify.send(to, subject, body, channels=None, priority=0, record_id=None). Sends a notification.toaccepts a user id, an email,role:code, orgroup:name.channelsis['in_app'],['email'], or both.
api
api.call(method, url, body=None, headers=None, params=None, timeout=30). Makes an outbound HTTP call and returns a dict withstatus_code,data,success, anderror.api.call_with_credential(credential_id, method, url, body=None). The same call, with a stored credential applied so no secret appears in the script.
log, approval, ai, workflow
log.info(message, data),log.warning(...),log.error(...). Structured audit log entries.approval.request(policy_code, record_id, record_data, title=None, description=None). Raises an approval against a named policy.approval.request_auto(record_id, record_data, trigger_type='create')matches a policy automatically.ai.complete(prompt, temperature=0.3, response_format='text', model_id=None, system_prompt=None, max_tokens=None). Calls a model and returnssuccessandcontent.workflow.trigger(workflow_name_or_id, trigger_data=None, inputs=None). Fires a workflow and returns immediately.
What you return
The sandbox reads your result off the result dict.
- A calculated rule sets
result['value']to the value it computed. - A validation rule sets
result['is_valid']toTrueorFalse, and setsresult['error_message']when it blocks the save.
Examples
A validation rule that blocks closing a record unless notes are present:
# Require closure notes when a record is closed# Trigger: before_update, rule type: validation
new_state = record.get('state')old_state = previous.get('state') if previous else None
if new_state == 'closed' and old_state != 'closed': closure_notes = (record.get('closure_notes') or '').strip() if not closure_notes: result['is_valid'] = False result['error_message'] = 'Closure notes are required when closing a record' else: result['is_valid'] = Trueelse: result['is_valid'] = TrueA calculated rule that looks a value up in another table, guarding every access:
# Set priority from an impact and urgency matrix# Trigger: before_insert, before_update, rule type: calculated
impact = (record.get('impact') or 'medium').lower()urgency = (record.get('urgency') or 'medium').lower()
rows = db.query('acme_priority_matrix', {'impact': impact, 'urgency': urgency}, limit=1)
priority = 'medium'if rows: priority = rows[0].get('priority', 'medium')
result['value'] = priorityGood to know
- A missing field reads as
None, and calling a method onNoneraises. Guard reads with a default:(record.get('impact') or 'medium').lower(). - A query can return an empty list. Check
if rows:before readingrows[0]; never index a query result blind. - Only a fixed set of modules imports. You may import
datetime,json,math,random,re,collections,itertools,functools,decimal,hashlib,base64,uuid, andtime. Anything else is unavailable. - The sandbox blocks system reach.
openandfile,os,sys,subprocess,eval,exec,compile, and__import__are all blocked. To make an outbound call, useapi. To read or write records, usedb. - Operators still apply. A condition that gates the rule uses the same vocabulary as everywhere else. See Operators.
Best practices
The habits that keep a script body reliable:
- Guard every field read with a default, so a missing or null value never crashes the
script:
(record.get('impact') or 'medium').lower(). - Check a query before you use it. Confirm the list is non-empty before indexing, and
request only the fields you need with
select_fields. - Keep each script small and single-purpose. One calculation or one validation reads and tests far better than a long branching body.
- Reach for a helper, not an import. Use
dbfor records,apifor outbound calls, andnotifyfor messages, rather than trying to pull in a module. - Return through
result. Setresult['value']for a calculation, orresult['is_valid']andresult['error_message']for a validation; do not rely on side effects to signal the outcome.
Verification
You know a script works when:
- A calculated rule leaves the expected value on the record.
- A validation rule blocks the save with your message when the data is wrong, and lets it through when the data is right.
- The audit log shows your
logentries, and anynotifyordb.insertside effect appears.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Script errors on a missing attribute | A field was None and a method was called on it | Read with a default: (record.get('x') or '').strip() |
| Index error on a query result | The query returned no rows | Check if rows: before rows[0] |
| Import fails | The module is not on the allow-list | Use one of the allowed modules, or a helper (api, db) |
| A write does nothing | The script runs in a read-only context | Attach the script where writes are permitted |
Related
- Business rules: where a script body attaches to a table event.
- Workflows: where a script step runs inside a process.
- Blueprint authoring: packaging rules and workflows that carry these scripts.
- Operators: the condition vocabulary.