Automation Scripts in MAS: Patterns That Scale — From Launch Points to Production
Category: Maximo Manage Deep Dives Slug: automation-scripts-patterns-2026 Tags: Automation Scripts, Jython, Python, Launch Points, Integration, MAS 9.1,
Category: Maximo Manage Deep Dives
Slug: automation-scripts-patterns-2026
Tags: Automation Scripts, Jython, Python, Launch Points, Integration, MAS 9.1, Best Practices, RBAs
Read Time: 11 min
Audience: Maximo developers, technical consultants, system integrators
Summary:
Automation scripts remain Maximo's most powerful extensibility mechanism — and the most frequently misused. This deep-dive covers modern patterns for script architecture, performance optimization, and production-ready deployment in MAS 9.x.
We'll explore object vs. attribute launch points, implicit variable pitfalls, and how to structure scripts for maintainability across upgrades.
Why Automation Scripts Still Matter in 2026
Maximo's automation scripting framework — introduced in 7.5 and now deeply embedded in MAS 9.x — remains the go-to mechanism for extending business logic without compiling Java. With MAS's shift to containerized OpenShift deployments, the ability to push business logic through the UI (or import via Migration Manager) rather than rebuilding images has only grown in importance.
But here's the problem: most Maximo implementations accumulate scripts organically. What starts as a simple attribute launch point grows into a tangle of interdependent scripts with no versioning, minimal error handling, and zero test coverage.
This article is about writing scripts that survive upgrades, scale across instances, and don't wake you up at 3 AM.
The Launch Point Matrix
Before diving into patterns, let's be precise about what launch points exist in MAS 9.x and when to use each:
| Launch Point | Trigger | Best For | Watch Out |
|---|---|---|---|
| Object | CRUD events on MBOs | Cross-field validation, audit trails, integrations | Performance — fires on every row |
| Attribute | Field-level changes | Single-field logic, conditional formatting | Can fire multiple times per save |
| Action | UI actions, workflows | User-initiated processes | Limited implicit variables |
| Condition | Expression evaluation | Conditional UI, security, workflow branching | Must return boolean |
| Integration | MIF/Publish Channel processing | Data transformation, enrichment | Inbound vs. outbound context differs |
| Custom | Programmatic invocation | Scheduled tasks, cron-based logic | No implicit MBO — you build everything |
Object vs. Attribute: A Decision Framework
# BAD: Attribute launch point that queries related objects
# This fires on EVERY attribute change, including batch updates
def check_open_work_orders():
asset = mbo
woset = asset.getMboSet("WORKORDER")
if not woset.isEmpty():
# Expensive query on every save
pass
# GOOD: Object launch point with save-point filtering
def check_open_work_orders_on_save():
# Only fires on save, not every attribute change
asset = mbo
if asset.isModified():
woset = asset.getMboSet("ACTIVEWORKORDER") # Use saved queries
if woset.count() > 0:
# Validate business rule
pass
Rule of thumb: If your logic involves querying related MBOs, checking across fields, or performing I/O, use an object launch point. If it's purely about transforming a single field's value, an attribute launch point is appropriate.
Implicit Variables: Know Your Context
Every Maximo developer learns the implicit variables the hard way — by trial, error, and cryptic log messages. Here's the authoritative reference for MAS 9.x:
Object Launch Point:
mbo → The MBO instance
mboname → String name of the MBO
app → Application name (if in UI context)
user → Current user ID
interactive → Boolean: UI vs. integration context
Attribute Launch Point:
mbo → The MBO instance
mboname → String name of the MBO
app → Application name
user → Current user ID
Action Launch Point:
mbo → The MBO instance (may be null for list-tab actions)
app → Application name
Integration Launch Point:
irData → StructureData for inbound
erData → MBO or StructureData for outbound
mbo → Current MBO (outbound only)
ctx → Processing context
The Integration Context Trap
This is the bug I've fixed most often in production environments:
# DANGEROUS: Assumes UI context
# Works fine in the application... crashes in integration
def validate_status():
global app, user
if app == "WOTRACK":
wo = mbo
# This works in the UI
# But app == None during MIF processing
# → NameError, stack trace, failed message
# SAFE: Context-aware validation
def validate_status_safe():
wo = mbo
# Check if we're in interactive context
try:
if interactive:
# UI-specific logic
pass
except NameError:
# Non-interactive context (MIF, crontask, escalation)
pass
# Core validation — always runs, regardless of context
if wo.getString("STATUS") == "COMP" and wo.isNull("ACTFINISH"):
wo.setFieldError("STATUS", "completedWorkOrder",
"Cannot complete without actual finish date")
Pattern 1: The Script Library Pattern
Don't copy-paste logic across scripts. Use the ScriptInclude pattern to build a reusable library:
Step 1: Create a library script
# Script: LIB_COMMON_UTILS
# ScriptInclude with no launch point
# Language: jython
from java.util import Date
from java.text import SimpleDateFormat
import re
class CommonUtils:
@staticmethod
def isValidEmail(email):
"""Validate email format"""
if email is None:
return False
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))
@staticmethod
def getCurrentDateTime(format_str="yyyy-MM-dd HH:mm:ss"):
"""Get formatted current datetime"""
sdf = SimpleDateFormat(format_str)
return sdf.format(Date())
@staticmethod
def logWithContext(message, level="INFO"):
"""Log with MBO context where available"""
import sys
frame = sys._getframe(1)
caller_mbo = frame.f_locals.get('mbo', None)
caller_mboname = frame.f_locals.get('mboname', 'unknown')
if caller_mbo:
uid = caller_mbo.getUniqueIDValue() if caller_mbo.getUniqueIDValue() else "new"
prefix = "[{0}:{1}]".format(caller_mboname, uid)
else:
prefix = "[{0}]".format(caller_mboname)
print("{0} [{1}] {2}".format(prefix, level, message))
@staticmethod
def safeGetMboSet(parent_mbo, relationship, where_clause=None):
"""Safely get MboSet with error handling"""
try:
if where_clause:
return parent_mbo.getMboSet(relationship, where_clause)
return parent_mbo.getMboSet(relationship)
except Exception, e:
print("[ERROR] Failed to get MboSet {0}: {1}".format(relationship, str(e)))
return None
# This line exposes the class to other scripts
Step 2: Consume the library
# Script: WO_VALIDATE_REPORTEDBY
# Object Launch Point on WORKORDER - Add/Update, Before Save
from LIB_COMMON_UTILS import CommonUtils
def validateReportedBy():
try:
wo = mbo
reported_email = wo.getString("REPORTEDBYEMAIL")
if reported_email and not CommonUtils.isValidEmail(reported_email):
CommonUtils.logWithContext(
"Invalid reported-by email: {0}".format(reported_email),
"WARN"
)
wo.setFieldError(
"REPORTEDBYEMAIL",
"invalidEmail",
"Please enter a valid email address for Reported By"
)
except Exception, e:
CommonUtils.logWithContext("Script failed: {0}".format(str(e)), "ERROR")
validateReportedBy()
This approach gives you centralized logic, consistent error handling, and one place to fix bugs.
Pattern 2: The Configuration-Driven Pattern
Hard-coded values in scripts are an upgrade nightmare. Externalize your configuration:
# Script: WO_AUTO_PRIORITY
# Object Launch Point on WORKORDER - Add, Before Save
from java.util import HashMap
# Configuration — move this to a MAXVAR or System Property in production
PRIORITY_RULES = {
"SAFETY": {"priority": 1, "sla_hours": 4},
"PRODUCTION": {"priority": 2, "sla_hours": 24},
"QUALITY": {"priority": 3, "sla_hours": 72},
"ROUTINE": {"priority": 4, "sla_hours": 168}
}
def getConfig(key, default=None):
"""Get configuration from MAXVARS or system properties"""
try:
from psdi.server import MXServer
mxserver = MXServer.getMXServer()
maxvar = mxserver.getMaximoDD().getMaxVarValue(key, True)
if maxvar and maxvar.strip():
return maxvar.strip()
except:
pass
return default
def autoAssignPriority():
wo = mbo
# Only run for new work orders without explicit priority
if not wo.isNew() or not wo.isNull("WOPRIORITY"):
return
worktype = wo.getString("WORKTYPE")
if not worktype:
return
rule = PRIORITY_RULES.get(worktype.upper())
if rule:
wo.setValue("WOPRIORITY", rule["priority"], 2) # 2 = no access check
wo.setValue("SLALIMIT", rule["sla_hours"], 2)
autoAssignPriority()
Pattern 3: The Async Processing Pattern
Some operations shouldn't block the user's save. Use MIF JMS queues for fire-and-forget processing:
# Script: WO_TRIGGER_EXTERNAL_SYNC
# Object Launch Point on WORKORDER - Update, After Save
from psdi.iface.mic import MicService
from psdi.util import MXCipher
import json
def enqueueForExternalSync():
wo = mbo
# Only for approved work orders
if wo.getString("STATUS") != "APPR":
return
# Build payload
payload = {
"wonum": wo.getString("WONUM"),
"siteid": wo.getString("SITEID"),
"description": wo.getString("DESCRIPTION"),
"assetnum": wo.getString("ASSETNUM"),
"location": wo.getString("LOCATION"),
"status": "APPR",
"changedate": str(wo.getDate("CHANGEDATE"))
}
try:
# Send to external queue via publish channel
mic = MicService(mbo.getUserInfo())
mic.publish("EX_WO_SYNC", json.dumps(payload))
except Exception, e:
# Log but don't block the save
print("[WARN] Failed to enqueue WO sync for {0}: {1}".format(
wo.getString("WONUM"), str(e)))
# Optionally flag for retry
wo.setValue("EXTSYNCSTATUS", "QUEUE_FAILED", 2)
enqueueForExternalSync()
Performance Considerations
1. Early Exit Pattern
def validateWorkOrder():
wo = mbo
# Exit immediately if nothing relevant changed
if not (wo.isNull("ASSETNUM") and wo.isModified("STATUS")):
return # Skip expensive logic
2. Batch Operation Awareness
# When mass-updating via MIF or escalations, avoid per-row queries
# Instead, detect batch mode and adjust behavior
def isBatchMode():
try:
from psdi.server import MXServer
# Check if this is a MIF processing session
return not interactive
except:
return True # Assume batch when uncertain
3. MBO Set Cleanup
woset = None
try:
woset = asset.getMboSet("WORKORDER")
# Process...
finally:
if woset:
woset.close() # Prevent memory leaks in long-running sessions
Testing Automation Scripts
The lack of a built-in testing framework is a known gap. Here's a pragmatic approach:
# Script: TEST_WO_AUTO_PRIORITY
# Action Launch Point — invoke manually from UI for testing
from WO_AUTO_PRIORITY import autoAssignPriority
def runTest():
global mbo
# Test case 1: SAFETY worktype should get priority 1
mbo.setValue("WORKTYPE", "SAFETY")
mbo.setValueNull("WOPRIORITY")
autoAssignPriority()
assert mbo.getInt("WOPRIORITY") == 1, "Safety worktype should be priority 1"
print("✓ Test 1 passed: SAFETY → Priority 1")
# Test case 2: Unknown worktype should remain null
mbo.setValue("WORKTYPE", "UNKNOWN")
mbo.setValueNull("WOPRIORITY")
autoAssignPriority()
assert mbo.isNull("WOPRIORITY"), "Unknown worktype should not set priority"
print("✓ Test 2 passed: UNKNOWN → No change")
runTest()
Migration to MAS 9.x: Script Changes
MAS 9.x runs scripts in a containerized environment. Key differences from 7.6:
- File system access: Assume read-only
/script/path. Don't write temp files. - Java version: Java 17 in MAS 9.x (vs. Java 8 in 7.6). Jython handles this transparently for most scripts, but custom Java classes may need recompilation.
- Library access: External JARs must be mounted in the container image or loaded via the
libdirectory. - Logging:
print()goes to the pod log. UseMXLoggerfor structured logging in production.
The Bottom Line
Automation scripts are Maximo's Swiss Army knife — incredibly versatile but easy to misuse. The patterns above give you a foundation for writing scripts that survive upgrades, scale across environments, and maintain performance under load. Invest in script libraries early. Externalize configuration. And always test in both interactive and batch contexts before promoting to production.
Sources: IBM Maximo Automation Scripting Documentation; MAS 9.1 Developer Reference; Community best practices from IBM Maximo Technical Touchpoint (May 2026)