Perform Script on Server (PSOS) is one of the most powerful tools in the FileMaker developer's kit. Offloading heavy processing to the server reduces network traffic, removes load from client machines, runs calculations and finds closer to the data, and can dramatically improve performance for batch operations, report generation, and integration workflows. Once you understand what PSOS is capable of, it's tempting to reach for it constantly.
And then it breaks in production in ways that never appeared in development.
The problem is almost always the same. The developer wrote the script on the client, tested it on the client, and it worked perfectly. They wrapped it in a PSOS call, it ran without error, but the output was wrong, the data wasn't written, the global wasn't populated, or the navigation didn't happen. Nothing in FileMaker's error reporting pointed to the cause. The script "ran successfully." It just didn't do what it was supposed to do.
This is PSOS context blindness. It's not a FileMaker bug. It's what happens when you write scripts in a client execution model and run them in a server execution model without understanding the fundamental differences between the two. PSOS runs in a separate, stripped-down session on the server with no UI, no layout context, no user interaction, no client-side globals, and a carefully bounded set of supported script steps. Code that assumes any of these things exist will fail silently, incompletely, or in ways that are extremely difficult to diagnose.
I've spent a lot of time tracking down these kinds of failures, and this post covers what PSOS actually is under the hood, where the context differences bite you hardest, how to diagnose failures that leave no obvious evidence, and how to architect scripts that are genuinely PSOS-safe from the ground up.
What PSOS Actually Is: The Server Session Model
To understand why PSOS behaves differently, you need a clear mental model of what happens when you execute Perform Script on Server.
A Separate Session, Not a Remote Procedure Call
When a client calls PSOS, FileMaker Server does not run the script inside the calling client's session. It spawns a new, independent server-side session with its own memory space, its own global variable scope, its own record locking footprint, and its own execution thread. This session is headless: it has no window, no layout rendering, no user interface layer, and no connection to the client's display state.
The client session and the server session are peers on the same hosted file, not a parent-child relationship. The client doesn't pause while the server session runs (in the non-blocking model). The server session can't reach back into the client's memory. Data exchange between the two sessions happens only through the script parameter (client to server) and the script result (server to client, via Get(ScriptResult) after the PSOS call returns in blocking mode).
This architecture has important consequences:
- Anything the client set up before the PSOS call (globals, found sets, current record, window state) is not visible to the server session. The server session starts fresh.
- Anything the server session modifies in its own memory (global variables, global fields) is not visible to the client after the PSOS call returns. The sessions don't share memory.
- The server session has no UI layer. Any script step that depends on a layout, a window, or user interaction either does nothing or produces an error.
Blocking vs. Non-Blocking PSOS
PSOS offers two execution modes. Non-blocking (asynchronous) means the client fires the PSOS call and immediately continues executing. The server script runs independently. The client has no way to receive the server script's result because there's no synchronization point. This is the right model for fire-and-forget operations: send an email, process a batch update, trigger an integration call.
Blocking (synchronous) means the client fires the PSOS call and waits for the server script to complete before continuing. When the server script finishes, the client can read its result via Get(ScriptResult). This is the right model for operations where the client needs data back from the server: retrieve a computed value, check a server-side condition, return a status.
Choosing the wrong mode is a common mistake. Using blocking PSOS for long-running operations freezes the client UI for the duration. Using non-blocking PSOS when you need the result means the client continues before the result exists.
The Server Session's Execution Environment
The server session's capabilities are a strict subset of the client's. Specifically:
- No UI: No windows, no layouts (in the traditional sense), no dialogs, no user interaction
- No client-side globals: Global variables (
$$variables) start empty; global fields contain their stored default values from the file, not what the client had set - No interactive script steps: Steps that require user input or display content to a user are unsupported or do nothing
- No plug-ins: Client-installed plug-ins aren't available in server sessions (server-side plug-ins must be separately deployed to the server)
- Limited navigation: Layout navigation steps execute but have no visual effect; they do affect context for field access
- No
Get()functions that depend on client state:Get(WindowName),Get(DesktopPath),Get(DocumentsPath),Get(PreferencesPath), and similar client-environment functions return empty or incorrect values
What the server session can do: read and write records, perform finds, run calculations, call subscripts, make Insert from URL calls, write to log tables, execute ExecuteSQL, work with JSON, and pass results back through script results. For data-heavy operations, this is sufficient and much faster than doing the same work on the client.
The Context Blindness Problems
"Context blindness" encompasses several distinct categories of PSOS failure. Each has a different root cause and a different mitigation strategy.
Global Variable Blindness
This is the most common PSOS failure, and the one that produces the most confusion. A developer sets global variables on the client:
Set Variable [$$UserPreference; Value: "metric"]
Set Variable [$$FilterDate; Value: Get(CurrentDate)]
Set Variable [$$CurrentUserID; Value: USERS::ID]
These globals are set, the script logic on the client works correctly, and then a PSOS subscript is called that depends on them. Inside the PSOS session, $$UserPreference, $$FilterDate, and $$CurrentUserID are all empty. The server session never inherited them. Any logic that branches on these globals produces wrong results, usually silently, because If [$$FilterDate = ""] simply takes the else branch without error.
The fix: pass everything the server script needs through the script parameter. Global variables aren't a communication channel between client and server sessions. The parameter is.
A common pattern is to bundle all required values into a JSON object as the parameter:
Set Variable [$param; Value:
JSONSetElement ( "{}" ;
["userID"; USERS::ID; JSONNumber];
["filterDate"; Get(CurrentDate); JSONString];
["preference"; $$UserPreference; JSONString]
)
]
Perform Script on Server ["ProcessData"; Parameter: $param]
Inside the server script, all values are extracted from the parameter:
Set Variable [$userID; Value: JSONGetElement(Get(ScriptParameter); "userID")]
Set Variable [$filterDate; Value: JSONGetElement(Get(ScriptParameter); "filterDate")]
Set Variable [$preference; Value: JSONGetElement(Get(ScriptParameter); "preference")]
The server script is now self-contained. It has no dependency on client session state.
Global Field Blindness
Global fields behave differently from global variables in a PSOS context, and the difference is subtle enough to cause real confusion.
Global fields are stored in the file. In a PSOS session, they contain the value they had when the file was last opened on the server, or the value set by the most recent server session that modified them. Not what the current client session has in memory.
So if a client script sets GLOBALS::gSearchTerm to "Smith" and then calls PSOS, the server session may see that field as empty, or as whatever the last server-side operation set it to. The client's in-memory value isn't transmitted to the server.
The compounding problem: developers often use global fields as a lightweight parameter-passing mechanism, setting them before a PSOS call and reading them inside the server script. This works unreliably. In low-traffic environments it may appear to work (the server session reads the field before any other session changes it), creating the illusion of a valid pattern. Under concurrent load, it fails intermittently and non-deterministically. I've seen this one bite teams hard in production.
The fix is the same: use the script parameter for all client-to-server communication. If the server script needs to set a global field for its own internal use, that's fine. But don't rely on a client-set global field being visible to the server session.
Found Set Blindness
When a client calls PSOS, the client may have a specific found set active, perhaps the result of a constrained find the user just performed, or a programmatically constructed set of records to process. The server session has no awareness of this found set. It starts with all records shown.
A script that depends on operating against "the current found set" will operate against all records instead when run via PSOS. If the script performs a delete, a batch update, or any operation that modifies records based on an assumed found set, the consequences can be severe.
The server script must reconstruct the found set itself, from criteria passed in the parameter. Don't pass record IDs and expect the server to "know" which records are in scope. Pass the criteria that define the found set, and let the server script perform its own find.
For cases where the client has a dynamic found set that can't easily be expressed as criteria (user-constructed finds, multi-field constraints), pass the record IDs explicitly as a JSON array in the parameter:
# Client: collect record IDs from current found set
Set Variable [$recordIDs; Value: "[]"]
Go to Record/Request/Page [First]
Loop
Set Variable [$recordIDs; Value:
JSONSetElement($recordIDs; "[" & Get(FoundCount) & "]"; TABLE::ID; JSONNumber)
]
Go to Record/Request/Page [Next; Exit after last]
End Loop
Perform Script on Server ["ProcessRecords"; Parameter: $recordIDs]
Inside the server script, iterate over the passed IDs and navigate to each record explicitly.
UI Step Blindness
This category covers script steps that simply don't work, or work differently, in a server session. The server session has no screen to render, no user to interact with, and no window in the traditional sense.
Here are some of the steps that do nothing or return errors in PSOS:
Show Custom Dialogdoes not execute; no dialog appearsAllow User Aborthas no effect (there is no user)Set Window Title,Move/Resize Window,Scroll Window,Freeze Window,Refresh Windowall have no effectPrintandPrint Setupare not supportedSend Mailis not supported client-side; use Send Mail via SMTP on serverOpen URLis not supported in server sessionShow/Hide ToolbarsandView Ashave no effectEnter Browse/Find Modeexecutes but has no visible effect; context changes are real
The dangerous ones are the silent no-ops. A script step that errors loudly at least tells you something is wrong. A step that silently does nothing allows the script to continue as if the step succeeded. If your script's logic depends on a custom dialog response, a window state, or a layout-triggered refresh, the server session will sail past those steps and the subsequent logic will be operating on false assumptions.
The fix: audit every script intended for PSOS execution against the list of unsupported steps. Remove or conditionally bypass them. Use Get(ApplicationVersion) or Get(SystemPlatform) to detect server execution context and branch accordingly:
If [Left(Get(ApplicationVersion); 6) = "Server"]
# Server-safe path
Else
# Client path with UI steps
End If
Note: Get(ApplicationVersion) returns a string beginning with "Server" when running in a server session (PSOS or scheduled script). This is the standard detection method.
Plug-In Blindness
Client-installed FileMaker plug-ins aren't available in server sessions. If a script calls a plug-in function (BaseElements, MBS/MonkeyBread, 360Works, or any other) and that plug-in isn't separately deployed and enabled on the server, the function call will fail. Depending on how the plug-in failure surfaces, this may produce an error, return empty, or silently skip the step.
This is particularly insidious because plug-in calls often don't look like plug-in calls in the script editor. They appear as Set Variable [$result; Value: BE_FilePath(...)] or similar. A developer reviewing the script may not immediately recognize that the calculation depends on a plug-in function.
The fix: audit all calculation expressions used in PSOS scripts for plug-in function calls. For each one, verify that the plug-in is deployed on the server and that the server-side plug-in version supports the same function signatures as the client version. If a plug-in is required for some operations and not available server-side, the architecture may need to change. The plug-in operation runs client-side, its result is passed to PSOS via parameter, and the server-side script works from that result rather than calling the plug-in directly.
Navigation Step Side Effects
Layout navigation steps (Go to Layout, Go to Record/Request/Page, Go to Related Record) execute in PSOS sessions, but their behavior is different from the client because there's no visible UI. This matters because these steps have two effects: a display effect (change what the user sees) and a context effect (change the execution context for subsequent script steps).
In a PSOS session, the display effect is absent. The context effect is real.
This means Go to Layout ["Invoices"] in a PSOS script actually does change the execution context to the INVOICES table occurrence, which is why PSOS scripts that access fields need to navigate to an appropriate layout or use explicit table-qualified field references. But the developer must be very deliberate about which layout they navigate to, because there's no visual feedback to confirm the context change worked as expected.
Go to Related Record in a PSOS session is particularly tricky. It traverses a relationship and changes the found set on the server, but it doesn't open a new window or change what the user sees. In a complex graph with multiple relationship paths, GTRR in a PSOS session may traverse a different path than expected, or arrive in a context the developer didn't intend.
In PSOS scripts, prefer explicit finds and explicit layout navigation over Go to Related Record. Use Go to Layout with a specific layout name, then Perform Find with explicit criteria, rather than GTRR which combines navigation and find in a way that's sensitive to context. Keep navigation in PSOS scripts minimal and explicit.
Get() Function Blindness
Several Get() functions return client-environment values that don't exist in a server session. Here are the key ones to watch for:
Get(WindowName)returns an empty stringGet(WindowHeight)andGet(WindowWidth)return 0Get(DesktopPath)returns empty or a server path (not the client desktop)Get(DocumentsPath)andGet(PreferencesPath)return server pathsGet(FilePath)returns the server file path (different from the client path)Get(UserName)returns the server session's account name (may be different from the client user)Get(AccountName)returns the account used to open the server session (may be a service account)Get(SystemLanguage)returns the server OS language, not the client language
The most dangerous of these in practice are the account and user identity functions. A script that uses Get(AccountName) to determine what the current user can do, or to write an audit record of who performed an action, will record the server session's account, not the user who initiated the PSOS call from the client.
If your PSOS script needs to know who initiated it, the client must pass the account name as part of the script parameter:
# Client passes identity to server
Set Variable [$param; Value:
JSONSetElement("{}" ;
["initiatedBy"; Get(AccountName); JSONString];
["initiatedAt"; Get(CurrentTimestamp); JSONString]
)
]
Perform Script on Server ["AuditedOperation"; Parameter: $param]
Diagnosing PSOS Failures
PSOS failures are among the most difficult to diagnose in FileMaker because the server session has no visible UI and errors don't surface to the client in most configurations. Here's a systematic approach I've found effective.
Check the FileMaker Server Logs
FileMaker Server writes event and error logs accessible through the Admin Console (or directly in the server's log directory). When a PSOS session encounters an unhandled error or terminates unexpectedly, the log records it. The event log is your first stop for any PSOS failure investigation.
Look for error codes associated with the script name, session open/close events that indicate the PSOS session terminated earlier than expected, and timestamps that correlate the server session with the client action that triggered it.
Instrument the Script with a Log Table
Client scripts can Show Custom Dialog for debugging. PSOS scripts can't. The equivalent is a Log table, a dedicated table where the script writes diagnostic records at key execution points:
# At script start
Perform Script ["_WriteLog"; Parameter:
JSONSetElement("{}";
["script"; Get(ScriptName); JSONString];
["event"; "START"; JSONString];
["param"; Get(ScriptParameter); JSONString];
["timestamp"; Get(CurrentTimestamp); JSONString]
)
]
# At key decision points
Perform Script ["_WriteLog"; Parameter:
JSONSetElement("{}";
["script"; Get(ScriptName); JSONString];
["event"; "Executing find for userID: " & $userID; JSONString];
["timestamp"; Get(CurrentTimestamp); JSONString]
)
]
# At error detection
Perform Script ["_WriteLog"; Parameter:
JSONSetElement("{}";
["script"; Get(ScriptName); JSONString];
["event"; "ERROR"; JSONString];
["errorCode"; $error; JSONNumber];
["context"; $contextDescription; JSONString];
["timestamp"; Get(CurrentTimestamp); JSONString]
)
]
The _WriteLog script receives the JSON parameter and writes a new record to the Log table. A log viewer layout lets developers monitor what server scripts are doing in real time.
This isn't just a debugging tool. It's production infrastructure. In practice, PSOS scripts in production should always log their start, completion, record counts processed, and any errors encountered. Silent server-side processing is a liability.
Test in a Controlled Server Session
Rather than calling PSOS from a complex client workflow, create a test harness: a simple layout with a "Test PSOS" button that calls the server script with a known, hard-coded parameter. This isolates the server-side behavior from any client-side state that might be contaminating the environment.
If the script works correctly when called with a known parameter from the test harness but fails when called from the real workflow, the problem is in what the real workflow is (or isn't) passing.
Simulate Server Context on the Client
For initial development and debugging of PSOS scripts, you can simulate server context on the client. Clear all global variables before calling the script. Set all global fields to their file-default values. Ensure no specific found set is active in any relevant table. Run the script with only the parameter it would receive in a real PSOS call.
If the script fails in this simulated context, it would also fail in a real PSOS session. Fix it here, where you have access to the debugger and dialog-based inspection.
Use the Script Debugger via FileMaker Pro
FileMaker 19.3+ allows attaching a debugger to server-side script sessions from FileMaker Pro Advanced. This is invaluable for diagnosing complex PSOS failures. Enable server debugging in the Admin Console, attach from the client, and step through the PSOS script with full variable inspection.
This feature isn't available in all deployment configurations and requires appropriate admin credentials, but when accessible it eliminates most of the guesswork in PSOS debugging.
Designing PSOS-Safe Scripts from Scratch
The most effective approach to PSOS context blindness is designing for server execution from the start, rather than retrofitting existing client scripts. Here are the architectural principles that make scripts genuinely PSOS-safe.
The Server Script Is a Black Box
Design every PSOS script as if it will always receive all its required context through the parameter, and will always return all its output through the script result. The server script should have zero dependencies on ambient session state. No globals, no found sets, no window state.
Think of it as a pure function: input, processing, output. Everything it needs comes in through the parameter. Everything the caller needs comes back through the result.
JSON as the Universal Parameter Contract
Use JSON for all PSOS parameter passing. A single string can carry an arbitrarily complex data payload, is self-documenting, handles type information cleanly, and is easy to construct and parse with FileMaker's native JSON functions.
Define a formal parameter schema for each PSOS script:
# PSOS Script: GenerateInvoice
# Parameter schema:
# {
# "customerID" : [number] - Required. ID of the customer record
# "lineItems" : [array] - Required. Array of {productID, qty, price}
# "initiatedBy" : [string] - Required. AccountName of calling user
# "options" : { - Optional.
# "sendEmail" : [boolean] - Whether to send confirmation email
# "draftMode" : [boolean] - Save as draft, don't post
# }
# }
#
# Result schema:
# {
# "success" : [boolean]
# "invoiceID" : [number] - Set if success = true
# "errorCode" : [number] - Set if success = false
# "errorMsg" : [string] - Set if success = false
# }
Document this schema in the script's comment header. It's the contract between the caller and the server script. When the script changes, the contract changes, and callers are updated accordingly.
Validate the Parameter First
The first block of a PSOS script should validate that the parameter contains everything required. If required fields are missing or malformed, exit immediately with a descriptive error result. Don't let a PSOS script run halfway on a bad parameter.
Set Variable [$param; Value: Get(ScriptParameter)]
Set Variable [$customerID; Value: JSONGetElement($param; "customerID")]
Set Variable [$initiatedBy; Value: JSONGetElement($param; "initiatedBy")]
# Validate required fields
If [IsEmpty($customerID) or IsEmpty($initiatedBy)]
Set Variable [$result; Value:
JSONSetElement("{}";
["success"; False; JSONBoolean];
["errorCode"; 1; JSONNumber];
["errorMsg"; "Missing required parameter: customerID or initiatedBy"; JSONString]
)
]
Exit Script [$result]
End If
This pattern catches the most common class of PSOS failures (missing parameters) at the script boundary rather than partway through execution.
Use Get(ApplicationVersion) Guards for Dual-Mode Scripts
Some scripts need to run on both the client and the server. Utility scripts called from both client workflows and PSOS contexts. Use Get(ApplicationVersion) to detect execution context and branch:
Set Variable [$isServer; Value:
Left(Get(ApplicationVersion); 6) = "Server"
]
If [$isServer]
# Server path: write to log table, use parameter for context
Perform Script ["_WriteLog"; Parameter: "Script started on server"]
Else
# Client path: can use UI, globals, window operations
Show Custom Dialog ["Processing..."]
End If
Make Subscripts Context-Aware
A PSOS script that calls subscripts introduces a risk: those subscripts may have been written for client execution and contain UI steps, global variable dependencies, or plug-in calls. Audit every subscript called from a PSOS context the same way you audit the parent script.
The cleanest architecture is a set of utility subscripts that are explicitly designed to be context-safe. No UI steps, no ambient state dependencies, fully parameterized. These scripts can be called from both client and server contexts. UI-specific logic lives in client-only wrapper scripts that are never called from PSOS.
Return Rich Results
A PSOS script that returns only a boolean success/failure leaves the caller with no ability to diagnose what went wrong. Return a rich JSON result that includes a success flag, any data the caller needs, error code and human-readable error message if failed, count of records processed (for batch operations), and a timestamp of execution.
# Success result
Set Variable [$result; Value:
JSONSetElement("{}";
["success"; True; JSONBoolean];
["invoiceID"; Invoice::ID; JSONNumber];
["processedAt"; Get(CurrentTimestamp); JSONString];
["lineItemCount"; $lineItemCount; JSONNumber]
)
]
Exit Script [$result]
# Failure result
Set Variable [$result; Value:
JSONSetElement("{}";
["success"; False; JSONBoolean];
["errorCode"; $error; JSONNumber];
["errorMsg"; $errorMessage; JSONString];
["failedAt"; $failureContext; JSONString];
["timestamp"; Get(CurrentTimestamp); JSONString]
)
]
Exit Script [$result]
The calling script parses this result and handles success and failure paths explicitly.
PSOS vs. Scheduled Scripts
Server-side scheduled scripts (run by FileMaker Server's scheduler) and PSOS scripts share many characteristics. Both are headless, both lack UI, both need explicit parameter-like mechanisms for context. But there are important differences worth understanding.
PSOS is triggered by a client script call, receives its parameter from the client, runs on-demand, and can return a result via Get(ScriptResult). Scheduled scripts are triggered by the FileMaker Server scheduler, have no external parameter source (the script provides its own context), run at configured times, and have no caller to receive a result.
The account context differs too. PSOS uses the configured account for the server session, while scheduled scripts use the account specified in the schedule configuration. And for logging, both should use a log table, but it's especially critical for scheduled scripts since there's no caller to receive status.
Scheduled scripts have an additional context challenge: they must be entirely self-sufficient. They determine their own scope (which records to process, what time range to cover) from data stored in the database, not from a parameter.
A common pattern for scheduled scripts is a control record: a row in a settings or scheduler table that stores configuration for the scheduled operation. It holds the last run timestamp, batch size, filter criteria, and an enabled/disabled flag. The script reads this record at startup to determine what to do, then updates it at completion with the new last-run timestamp and a status message. This pattern also provides an audit trail.
Common Antipatterns and Their Fixes
These are the recurring mistakes I've seen cause PSOS failures in real production solutions.
Passing Context Through Globals
# Wrong
Set Variable [$$SelectedCustomerID; Value: Customer::ID]
Perform Script on Server ["ProcessCustomer"]
# Inside PSOS: reads $$SelectedCustomerID — always empty
# Right
Set Variable [$param; Value: JSONSetElement("{}"; "customerID"; Customer::ID; JSONNumber)]
Perform Script on Server ["ProcessCustomer"; Parameter: $param]
# Inside PSOS: reads JSONGetElement(Get(ScriptParameter); "customerID")
Assuming the Current Found Set
# Wrong — assumes PSOS sees the client's found set
Perform Script on Server ["ProcessFound"]
# Inside PSOS: Set Variable [$count; Value: Get(FoundCount)]
# Returns total record count, not the client's found set count
# Right — pass find criteria or IDs
Set Variable [$param; Value:
JSONSetElement("{}";
["findCriteria"; JSONSetElement("{}"; "status"; "Open"; JSONString); JSONObject]
)
]
Perform Script on Server ["ProcessFiltered"; Parameter: $param]
# Inside PSOS: extracts criteria, performs its own find
Using Show Custom Dialog in PSOS
# Wrong — does nothing in PSOS; script logic after the dialog is based on false assumptions
Show Custom Dialog ["Confirm"; "Process these records?"]
If [Get(LastMessageChoice) = 1]
# Processes records — runs unconditionally in PSOS because dialog never appeared
End If
# Right — confirmation happens client-side before calling PSOS
Show Custom Dialog ["Confirm"; "Process these records?"]
If [Get(LastMessageChoice) ≠ 1]
Exit Script []
End If
Perform Script on Server ["ProcessRecords"; Parameter: $param]
Using Get(AccountName) Inside PSOS for User Identity
# Wrong — returns server session account, not the user who triggered PSOS
Set Field [Log::ModifiedBy; Get(AccountName)]
# Right — client passes its identity in the parameter
# Inside PSOS:
Set Variable [$initiatedBy; Value: JSONGetElement(Get(ScriptParameter); "initiatedBy")]
Set Field [Log::ModifiedBy; $initiatedBy]
Long-Running PSOS in Blocking Mode
# Wrong — freezes client UI for the duration of a heavy batch operation
Perform Script on Server ["ProcessAllRecords"] # Blocking, takes 90 seconds
# Client is unresponsive for 90 seconds
# Right — use non-blocking PSOS for long operations; polling or log-based status tracking
Perform Script on Server ["ProcessAllRecords"; Non-blocking]
# Client continues; periodically checks a status record for completion
Calling Plug-In Functions Without Server-Side Verification
# Wrong — assumes client plug-in is available on server
Set Variable [$path; Value: BE_FilePath(TABLE::ContainerField)]
# Returns empty in PSOS if BaseElements not installed on server
# Right — verify plug-in availability, or restructure to avoid server-side plug-in dependency
If [Left(Get(ApplicationVersion); 6) = "Server"]
# Use server-safe alternative or pass the path from the client via parameter
Else
Set Variable [$path; Value: BE_FilePath(TABLE::ContainerField)]
End If
A Practical PSOS Script Template
Here's a production-ready template for a PSOS script that puts all of these principles together. I've found this structure reliable across a wide range of solutions.
# Script: PSOS_ProcessTemplate
# Execution: Server (PSOS or scheduled)
# Purpose: Template for server-side processing
#
# Parameter schema (JSON):
# {
# "requiredField" : [type] - Description
# "initiatedBy" : [string] - AccountName of calling user
# }
#
# Result schema (JSON):
# {
# "success" : [boolean]
# "recordsUpdated" : [number] - set on success
# "errorCode" : [number] - set on failure
# "errorMsg" : [string] - set on failure
# }
# --- Initialize ---
Set Error Capture [On]
Set Variable [$param; Value: Get(ScriptParameter)]
Set Variable [$startTime; Value: Get(CurrentTimestamp)]
Set Variable [$recordsUpdated; Value: 0]
Set Variable [$error; Value: 0]
Set Variable [$errorMessage; Value: ""]
# --- Log Start ---
Perform Script ["_WriteLog"; Parameter:
JSONSetElement("{}";
["script"; Get(ScriptName); JSONString];
["event"; "START"; JSONString];
["initiatedBy"; JSONGetElement($param; "initiatedBy"); JSONString];
["timestamp"; $startTime; JSONString]
)
]
# --- Validate Parameter ---
Set Variable [$requiredField; Value: JSONGetElement($param; "requiredField")]
Set Variable [$initiatedBy; Value: JSONGetElement($param; "initiatedBy")]
If [IsEmpty($requiredField)]
Set Variable [$errorMessage; Value: "Missing required parameter: requiredField"]
Go to Script ["_PSOS_ExitWithError"; Parameter:
JSONSetElement("{}";
["errorCode"; 100; JSONNumber];
["errorMsg"; $errorMessage; JSONString];
["script"; Get(ScriptName); JSONString]
)
]
Exit Script [
JSONSetElement("{}";
["success"; False; JSONBoolean];
["errorCode"; 100; JSONNumber];
["errorMsg"; $errorMessage; JSONString]
)
]
End If
# --- Navigate to Working Context ---
Go to Layout ["PSOS_WorkingLayout"]
# (This layout exists specifically for server-side processing,
# uses the correct table occurrence, contains no decorative objects)
# --- Perform Find ---
Enter Find Mode []
Set Field [TABLE::Status; $requiredField]
Perform Find []
Set Variable [$error; Value: Get(LastError)]
If [$error = 401]
# No records found — not an error, just nothing to process
Perform Script ["_WriteLog"; Parameter:
JSONSetElement("{}";
["script"; Get(ScriptName); JSONString];
["event"; "No records matched find criteria"; JSONString];
["timestamp"; Get(CurrentTimestamp); JSONString]
)
]
Exit Script [
JSONSetElement("{}";
["success"; True; JSONBoolean];
["recordsUpdated"; 0; JSONNumber]
)
]
Else If [$error ≠ 0]
Set Variable [$errorMessage; Value: "Find failed. Error: " & $error]
# Log and exit with error result
Exit Script [
JSONSetElement("{}";
["success"; False; JSONBoolean];
["errorCode"; $error; JSONNumber];
["errorMsg"; $errorMessage; JSONString]
)
]
End If
# --- Process Records ---
Go to Record/Request/Page [First]
Loop
# Transaction pattern on each record
Open Record/Request
Set Variable [$error; Value: Get(LastError)]
If [$error = 0]
Set Field [TABLE::ProcessedBy; $initiatedBy]
Set Field [TABLE::ProcessedAt; Get(CurrentTimestamp)]
Commit Records/Requests [No dialog]
Set Variable [$error; Value: Get(LastError)]
If [$error = 0]
Set Variable [$recordsUpdated; Value: $recordsUpdated + 1]
Else
Revert Record/Request [No dialog]
Perform Script ["_WriteLog"; Parameter:
JSONSetElement("{}";
["script"; Get(ScriptName); JSONString];
["event"; "Commit failed on record " & Get(RecordID); JSONString];
["errorCode"; $error; JSONNumber];
["timestamp"; Get(CurrentTimestamp); JSONString]
)
]
End If
Else
# Record locked — log and skip
Perform Script ["_WriteLog"; Parameter:
JSONSetElement("{}";
["script"; Get(ScriptName); JSONString];
["event"; "Record locked, skipped: " & Get(RecordID); JSONString];
["errorCode"; $error; JSONNumber];
["timestamp"; Get(CurrentTimestamp); JSONString]
)
]
End If
Go to Record/Request/Page [Next; Exit after last]
End Loop
# --- Log Completion and Return Result ---
Perform Script ["_WriteLog"; Parameter:
JSONSetElement("{}";
["script"; Get(ScriptName); JSONString];
["event"; "COMPLETE"; JSONString];
["recordsUpdated"; $recordsUpdated; JSONNumber];
["duration"; (Get(CurrentTimestamp) - $startTime) * 86400 & "s"; JSONString];
["timestamp"; Get(CurrentTimestamp); JSONString]
)
]
Exit Script [
JSONSetElement("{}";
["success"; True; JSONBoolean];
["recordsUpdated"; $recordsUpdated; JSONNumber]
)
]
PSOS and the Separation Model
If your solution uses the separation model (data file and UI file as separate hosted files), PSOS introduces additional context considerations.
A PSOS script always runs in the session opened against the file that contains the script. If your UI file calls Perform Script on Server, the server session is opened against the UI file, not the data file. Field access that crosses the file reference (from UI file to data file) works through the inter-file relationship, but it's slower and has different context implications than if the script were in the data file.
For heavy data processing, the script should ideally live in the data file. The UI file calls PSOS on the data file script, passing the required parameter. This keeps the server session's context close to the data and eliminates cross-file reference overhead in tight processing loops.
# UI file script — calls PSOS against data file script
Perform Script on Server [from file "DataFile"; "ProcessRecords"; Parameter: $param]
The from file option in the PSOS step specifies which hosted file the script executes in. Use this deliberately in separation model solutions.
A Practical Checklist for PSOS Scripts
Use this when reviewing or writing any script intended for server execution.
Parameter and Context
- All required context is passed through the script parameter (no global variable dependencies)
- No global field is used as a parameter-passing mechanism
- The script parameter schema is documented in the script header
- The script result schema is documented in the script header
- Parameter is validated at script start; script exits with error result if invalid
Execution Context
- No
Show Custom Dialog,Allow User Abort,Print,Open URL, or other UI-only steps Get(ApplicationVersion)guards used for any steps that differ between client and server- All
Get()functions verified for server-session behavior - No plug-in functions that aren't also deployed and enabled on the server
- Subscripts are audited for PSOS safety
Found Sets and Navigation
- Script performs its own find rather than assuming an inherited found set
- Layout navigation uses explicit layout names with known table occurrence contexts
Go to Related Recordusage is minimal and explicitly tested in server context
Error Handling
Set Error Capture [On]is the first stepGet(LastError)checked afterOpen Record/Request,Commit Records/Requests, andPerform Find- Error 401 (no records) is handled separately from other errors
- All error paths exit with a structured error result, not an empty result
Logging
- Script logs its start with parameter summary and timestamp
- Script logs its completion with record count and duration
- All error conditions are logged with record ID, error code, and context
- Skipped records (due to locks or conditions) are logged, not silently dropped
Result
- Script returns a structured JSON result in all code paths (success, error, no-records)
- Caller parses and handles the result explicitly
- For blocking PSOS, caller reads
Get(ScriptResult)and acts on it
Wrapping Up
PSOS context blindness is the collective term for a class of failures that occur when scripts written for client execution are run in a server session without accounting for the fundamental differences between the two environments. The server session has no UI, no inherited client globals, no client found set, no access to client-installed plug-ins, and a different resolution for Get() functions that depend on client state. Scripts that assume any of these things exist will fail, often silently, often partially, and rarely in a way that's immediately obvious.
The root causes are consistent: global variable dependencies instead of parameterized interfaces, UI steps embedded in logic scripts, ambient state assumptions instead of explicit context construction, and insufficient logging to make server-side failures visible.
The solutions are equally consistent. Treat every PSOS script as a black box with a formal parameter contract and a structured result. Validate parameters at the boundary. Pass all required context explicitly. Audit for unsupported steps. Use Get(ApplicationVersion) guards for dual-mode scripts. And instrument every server-side script with comprehensive logging.
PSOS is one of FileMaker's most powerful performance tools. Used correctly, with deliberate context architecture and rigorous parameter contracts, it can transform the scalability of a solution. Used carelessly, by running client scripts on the server without adaptation, it becomes a source of mysterious, intermittent failures that erode confidence in the system.
The difference is entirely in how you design the boundary between the client session and the server session. Design that boundary with the same rigor you'd bring to any API contract, and PSOS becomes reliable, fast, and debuggable.