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,

Share

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:

  1. File system access: Assume read-only /script/ path. Don't write temp files.
  2. 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.
  3. Library access: External JARs must be mounted in the container image or loaded via the lib directory.
  4. Logging: print() goes to the pod log. Use MXLogger for 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)

Read more