automate with ion
ION Actions
ION Actions are validation rules you write to enforce your own business logic in ION. An action is triggered by an event, such as creating an issue, completing a run step, or changing a procedure’s status. When triggered, it reads the data involved and can allow the change, block it with a message, or surface a non-blocking warning. Actions run inside the change itself: a blocked change is rejected before it saves, so the rule is enforced whether the change comes from the ION UI or the API. That makes actions the right tool for hard requirements, such as required fields on creation, approval gates, and status-transition guards. For alerting people after something happens, use notifications instead. You create and edit actions on the Actions page in ION, which has a built-in editor, or through the GraphQL API for programmatic deployment. For the create, edit, enable, and delete flows, and for enabling actions for your organization, see Manage actions.
Action anatomy
An action is defined by these parts. Title, target, and event are form fields; context and code are tabs in the built-in editor.
- Title: identifies the action and appears in its toast notification and the audit log.
- Target: the entity to watch, such as Run, Issue, or Procedure, chosen from a dropdown. See Action targets for the full list.
- Event (labeled Event Type in the editor): when the action runs against the target, one of Create, Update, or Delete.
- Context: a GraphQL query for the data your code needs, such as the target’s fields, related records, and custom attributes. Your code reads from the result.
- Code: a Python script that runs when the event fires. It reads the context and decides the outcome: raise
ValidationErrorto block the change with a message, raiseValidationWarningto warn without blocking, or do neither to allow it. The code is read-only. It can’t write data or call external systems.
Context query
The context is a GraphQL query that fetches the data your code evaluates. ION runs it against the entity that triggered the action and passes the result to your code.
{
run(id: $id) {
id
status
Attributes { key value }
}
}
Rules for the query:
- One entity, plus an optional
me. Query exactly one top-level entity, matching your target. You can addme { ... }as a second top-level selection for data about the acting user. - Use the singular camelCase name of the target as the query name, such as
runfor therunstarget orrunStepforrun_steps. See Action targets for the name of every target. The fields each entity exposes are what GraphiQL introspection returns for it in your environment. $idis resolved for you. ION substitutes the triggering entity’s ID, so$idis a convention you don’t fill in. Compound-key entities take their composite ID parameters instead; see Action targets.- No
edgesand one operation. Don’t use the connection oredgespattern, and define only one query.
Custom attributes
Custom attributes come back as a list under Attributes. Request all of them with Attributes { key value }, or filter to one with Attributes(filters: {key: {eq: "Pedigree"}}). Each attribute object exposes these fields:
key: the attribute name, such as “Pedigree” or “Release Date”.value: the value, which can be a string, number, date, boolean, or select value.id: the attribute record ID.type: the attribute type.allowedIonType: the linked entity type, when the attribute links to one.options: the allowed values for a select attribute.
Data your code receives
Your code gets one context dictionary. It always holds the entity you queried (under the target’s singular camelCase key), a changes object, and a currentUser object. It also holds me when your query requested it.
context = {
"run": { ... }, # the entity you queried; shape matches your query
"changes": { ... }, # what this event modified
"currentUser": { ... }, # who performed the change
}
Changes object
changes describes what the event modified. It’s keyed by the camel-cased table name, then by field, and each entry holds { "new": ..., "old": ... }. The old value is null on a create and the new value is null on a delete. Field names are snake-cased, and custom-attribute tables are keyed by the attribute’s name. A changes object for a procedure update can look like this:
{
"procedures": {
"status": { "new": "released", "old": "in_review" }
}
}
Other targets produce different keys, one per table the event touched. A few examples:
Issue update
{
"issues": {
"status": { "new": "in_progress", "old": "pending" },
"must_close_by_run_step_id": { "new": 2116, "old": null }
},
"issuesAttributes": {
"Issue Origin": { "new": "Engineering", "old": null }
},
"issueApprovalRequests": {
"status": { "new": "approved", "old": "pending" }
}
}
Inventory update
{
"partsInventory": {
"status": { "new": "unavailable", "old": "available" },
"lot_number": { "new": "Lot Test 123", "old": null },
"location_id": { "new": 19, "old": 67 }
}
}
Part kit update
{
"partsKits": {
"assigned_team_id": { "new": 2, "old": null },
"delivery_location_id": { "new": 19, "old": null }
},
"partKitAttributes": {
"Date Kitted": { "new": "2025-09-08T07:00:00", "old": null }
}
}
Use changes to branch on what actually changed rather than the final state. For example, to act only when a procedure’s status changes to released:
procedure_changes = context.get('changes', {}).get('procedures', {})
if procedure_changes.get('status', {}).get('new') == 'released':
# the procedure was just released; run your logic here
Custom attributes appear under the entity’s *Attributes key, such as partsInventoriesAttributes or runsAttributes, keyed by the attribute’s name. A few things to know about changes:
- It’s operation-wide. It holds changes for every entity the operation touched, not only your action’s target.
- On a create or delete, the entity’s entry is an empty
{}, since there are no field-level old and new values. step_id,run_step_id, and a redline’sinitial_stateandfinal_statenever appear in it.
Current user
currentUser is always present and describes who performed the change:
{
"email": "jane.doe@company.com",
"roles": ["admin", "engineer"],
"teams": ["Assembly", "QA"]
}
Branch on roles to gate a change by permission:
if "admin" not in context.get("currentUser", {}).get("roles", []):
raise ValidationError()
Writing action code
Action code is the body of a Python function that receives context. You don’t write the def line: ION wraps your code and passes in the context. Raise ValidationError to block the change, raise ValidationWarning to warn, or return without raising to allow it.
if context["run"]["status"] == "COMPLETE":
raise ValidationError("Cannot modify a completed run")
The code runs in a sandboxed Python 3.6 environment:
- Python 3.6 syntax only. No walrus operator (
:=), no f-string debug (f"{x=}"), nomatchstatements, and noX | Ytype unions. - No imports, no I/O. You can’t import modules or reach the file system, network, or database.
print()output is discarded. - You can use the built-ins (
len,any,all,sum,next,set, and so on), comprehensions,try/except, and local helper functions. - A computation budget applies. A very long computation is stopped with a user-visible error, so avoid deep loops over large collections.
The built-in editor checks these constraints before an action goes live. An action that fails validation is saved but set to an error status and doesn’t run until you fix it.
Blocking and warning
Raising ValidationError blocks the change; raising ValidationWarning lets it save and shows a non-blocking message. Use a validation action for hard requirements, such as a missing required field or an approval gate, and a warning action for soft ones, such as a skipped recommended step. An action raises one or the other, never both. You can pass a message to either. ION shows it to the user as [Rule {id}] {title} followed by your message; with no message, the user sees just [Rule {id}] {title}.
The message must be a plain string literal. ION injects the action’s ID and title automatically, and it silently drops a message built from an f-string, a variable, or any other expression, so the user sees only the title. This prevents dynamic strings from leaking internal data through a user-visible message.
# Preserved: a string literal
raise ValidationError("Run must have a QMS code before creation")
# Dropped: the custom message never reaches the user
msg = "Run must have a QMS code"
raise ValidationError(msg)
raise ValidationError(f"Run {run_id} is missing a QMS code")
How multiple actions combine
When an event and target combination matches several actions, those actions are squashed together at runtime. Squashing merges both the code and the requested context so every triggered action runs with the data it needs. They run in sequence in an order you don’t control, with validation actions before warning actions so a block takes precedence, and they share one merged context. Keep each action self-contained.
When two actions on the same target and event filter the same field differently in their context (for example, both query Attributes(filters: {...}) with different keys), the merged query fails. Give each filtered field an alias, such as deptAttr: Attributes(filters: {key: {eq: "Department"}}), and reference the alias in your code.
Common patterns
- Read defensively. A chained lookup like
context["run"]["procedure"]["type"]throws if any level isNone. Read one level at a time with a fallback:context.get("run", {}).get("procedure", {}).get("type"). - Skip early. Return when the action doesn’t apply, so the rest of the code only handles the relevant case.
if context.get("run", {}).get("procedure", {}).get("type") != "BUILD":
return
- Detect a status change. Compare the new and old values in
changesrather than the final state.
changes = context.get("changes", {}).get("runSteps", {}).get("status", {})
if changes.get("new") == "canceled" and changes.get("old") != "canceled":
raise ValidationError()
- Find an attribute in a list. Attributes come back as a list of
{key, value}. Pull one withnextand a default.
pedigree = next(
(attr.get("value") for attr in context.get("run", {}).get("Attributes", [])
if attr.get("key") == "Pedigree"),
None,
)
Seeing what an action did
Every time an action fires, ION records the run in its execution logs: what triggered it, the data it read, and the outcome. Use the logs to debug an action that blocks unexpectedly or never fires. See View action execution logs.
Troubleshooting
An action never fires: Confirm the action is enabled and its status is active, that the target matches the entity being changed, and that the event matches the operation. Actions must also be enabled for the organization.
An action errors instead of running: Check the action’s execution logs for the error. Common causes are a context query that requests a field the entity doesn’t have, or a Python syntax error in the code.
A custom message doesn't appear: ValidationError keeps only a plain string-literal message. A message built from an f-string, a variable, or any other expression is dropped.
Context data is missing: The context query must request every field your code reads. Field names are case-sensitive.
An action stops with a fuel-limit error: The action exceeded its computation budget. Simplify the logic or filter attributes in the query.
A custom attribute change isn't detected: Attribute changes appear under the entity’s *Attributes key in changes, and trigger an update on the parent entity.