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.

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:

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:

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:

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:

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

if context.get("run", {}).get("procedure", {}).get("type") != "BUILD":
      return
changes = context.get("changes", {}).get("runSteps", {}).get("status", {})
if changes.get("new") == "canceled" and changes.get("old") != "canceled":
      raise ValidationError()
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.