Skip to content

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.write for a business rule, platform.workflows.write for 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 with record.get('field').
  • previous. The record’s prior state on an update, or None on an insert. Compare against record to 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 or None.
  • db.count(table, filters=None). Returns an integer.
  • db.aggregate(table, field, operation='SUM', filters=None). Runs SUM, AVG, MIN, MAX, or COUNT.
  • 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. to accepts a user id, an email, role:code, or group:name. channels is ['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 with status_code, data, success, and error.
  • 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 returns success and content.
  • 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'] to True or False, and sets result['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'] = True
else:
result['is_valid'] = True

A 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'] = priority

Good to know

  • A missing field reads as None, and calling a method on None raises. Guard reads with a default: (record.get('impact') or 'medium').lower().
  • A query can return an empty list. Check if rows: before reading rows[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, and time. Anything else is unavailable.
  • The sandbox blocks system reach. open and file, os, sys, subprocess, eval, exec, compile, and __import__ are all blocked. To make an outbound call, use api. To read or write records, use db.
  • 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 db for records, api for outbound calls, and notify for messages, rather than trying to pull in a module.
  • Return through result. Set result['value'] for a calculation, or result['is_valid'] and result['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 log entries, and any notify or db.insert side effect appears.

Troubleshooting

SymptomCauseFix
Script errors on a missing attributeA field was None and a method was called on itRead with a default: (record.get('x') or '').strip()
Index error on a query resultThe query returned no rowsCheck if rows: before rows[0]
Import failsThe module is not on the allow-listUse one of the allowed modules, or a helper (api, db)
A write does nothingThe script runs in a read-only contextAttach the script where writes are permitted