Appearance
Plugins
Table of Contents
New to plugins? Follow Writing Your First Plugin to configure the CLI, create a template, and upload it to Factorial.
Factorial is a programmable system, letting organizations tailor Factorial to their use case. Plugins can do things like sync data to and from other systems, enforce business rules transactionally, and automate common tasks. We built plugins with AI in mind, and provide skills, docs, and AI integration to make writing plugins easier.
A plugin has a collection of scripts and optional secrets. Scripts must be written in a Python subset called Starlark. Plugins can be individually enabled/disabled. They are versioned, and go through a configurable approval process before they can be enabled. Uploading an existing draft with the same name and version replaces that draft. If the existing version is not in draft, the upload fails; either return the version to draft or increment the version in the plugin manifest. Existing versions remain available for inspection. Give each plugin a plain-language description in plugin.yaml saying what it does; it shows on the plugin's page, where you can also edit it. Omit description to keep the current one, including when you upload a new version. Plugins are tracked in audit history so that there is complete traceability on when plugins were enabled. Scripts within the same plugin all share the same namespace, so code can be reused within plugins.
Hooks, receivers, jobs, and actions expose a main(args) entrypoint. Inventory constraint scripts expose constraints(ctx). Each script file must represent only one entrypoint type.
Sharing script logic
Each hook, receiver, action, job, or inventory constraint in plugin.yaml must use a distinct entrypoint file. This includes two hooks subscribing to different events: they cannot share the same file path. Each event may have only one subscription per plugin.
To reuse validation or other logic, put it in a helper module and load it from separate entrypoint files:
yaml
hooks:
- event: inventorycreate
file: hooks/on_create.py
- event: inventory_cf_grade
file: hooks/on_grade.pyBoth entrypoint files can contain:
python
load("helpers.py", "validate_inventory")
def main(args):
validate_inventory(args)Define validate_inventory in helpers.py at the plugin root. Helper modules can be loaded by any number of scripts and do not need a manifest entry. Keep queries and writes inside functions; module initialization has no session.
Inventory constraints
Inventory constraint scripts narrow the inventory offered for an installation requirement on a work order task. Factorial applies their predicates before pagination and evaluates them again against current context when installing inventory. Every enabled plugin's constraints must pass, together with Factorial's built-in installation rules.
Register one constraint entrypoint in the existing plugin manifest:
yaml
name: pedigree
version: 1
inventory_constraints: pedigree.pyThe script returns a declarative expression. Ordinary Starlark branching chooses which rules apply; inventory represents the candidate inventory being filtered:
python
load("factorial:constraints", "inventory", "all_of", "any_of", "require")
def constraints(ctx):
allowed = {
"Tier 1": ["Tier 1"],
"Tier 2": ["Tier 1", "Tier 2"],
"Tier 3": ["Tier 1", "Tier 2", "Tier 3"],
}
if ctx.parent == None:
fail("A parent assembly is required to determine pedigree.")
pedigree = ctx.parent.fields.get("pedigree")
if pedigree not in allowed:
fail("Set the parent assembly's pedigree first.")
rules = [require(
inventory.field("pedigree").in_(allowed[pedigree]),
id="compatible_pedigree",
message="Component pedigree must satisfy the parent pedigree.",
)]
if ctx.work_order.fields.get("program") == "Flight":
rules.append(require(
all_of(
inventory.field("program").eq("Flight"),
any_of(
inventory.field("supplier_approved").eq(True),
inventory.field("waiver_reference").is_set(),
),
),
id="flight_material",
message="Flight material requires a matching program and supplier approval or a recorded waiver.",
))
return all_of(*rules)Create the referenced standard fields and options before enabling this plugin. The waiver condition above checks only that a reference is recorded; it does not establish whether the waiver covers the part or program.
Context and predicates
Factorial loads context from the selected task and requirement. Scripts cannot supply a different context to bypass constraints.
| Context | Available values |
|---|---|
ctx.requirement | id, part_id, quantity (decimal string), accepted_part_ids (including substitutes) |
ctx.parent | Output inventory id, part_id, fields; None if no active output is available. Uses the nearest work order ancestor with an output when the task's work order has none. |
ctx.work_order | The task's work order id and fields |
ctx.task | id |
fields is a dictionary indexed by field key. In constraint context, select values are option labels and multiselect values are lists of labels, matching filter operands. Ordinary plugin query includes still return option UUIDs. Numbers, dates, and date & time values are strings. Checkboxes are booleans. Unanswered fields are None, except checkboxes (False) and multiselects ([]). Renaming a key or option label requires updating scripts that refer to it.
inventory.field(key) selects a custom field, without the query API's cf_ prefix. Supported methods are eq, neq, in_, not_in, gt, gte, lt, lte, is_set, and is_unset, subject to the field's supported operators. Use strings for decimal operands to preserve precision, such as .gte("1.25"). Integer and boolean operands are also accepted. Membership takes a nonempty list or tuple of at most 100 values. Unknown fields, option labels, and unsupported operators produce errors.
Combine predicates with all_of(...) and any_of(...). Empty all_of() allows all otherwise eligible candidates; empty any_of() allows none. Wrap an expression in require(expression, message="...", id="...") to explain a rejection; id is optional. Use ordinary if statements for concrete context values. Using a symbolic predicate with if, and, or, or bool raises an error.
Scripts can load helper modules from their own plugin. They cannot query records, perform writes, or send HTTP requests. Execution is bounded by the plugin time and instruction limits; each returned expression is limited to 64 nodes, 16 nesting levels, and 16 distinct predicates. A script error stops the picker or installation rather than falling back to unconstrained results.
This prototype applies to task installation requirements. It does not constrain general inventory installation outside a task, consumption, or kitting, and does not recalculate historical fulfillment using pedigree rules.
Testing constraints
Use fctl plugins test with a fixture referencing existing records. Constraint fixtures evaluate only the selected script's predicates; they do not install inventory or check quantity, task status, or other built-in installation rules. Draft scripts can be tested before enabling the plugin.
json
[{
"name": "reject lower-pedigree component",
"script": "pedigree.py",
"input": {
"task_id": "EXISTING-TASK-UUID",
"requirement_id": "EXISTING-REQUIREMENT-UUID",
"inventory_id": "EXISTING-INVENTORY-GROUP-UUID",
"eligible": false
}
}]List the fixture file under fixtures in plugin.yaml, as with other plugin fixtures. eligible is the expected result; both allowed and rejected candidates can be checked. Script and configuration errors always fail the fixture.
Hooks
Hook scripts subscribe to a specific event in plugin.yaml. Factorial calls the script's main(args) function with a tuple of standardized HookEvent values. Each event provides a subject_id, an event name, and a data dictionary:
python
def main(args):
for event in args:
print(event.subject_id)
print(event.event)Manifest subscriptions and payloads use the same event name, such as commentcreate.
A hook invocation may contain multiple events. Hook scripts can send HTTP requests, raise validation errors or warnings, send notifications, schedule work, and modify custom fields.
A script sends a notification with factorial.notifications.create, to people by user or by role. It shows in their notifications under the plugin's name and links to the inventory or work order it is about:
python
factorial.notifications.create(inputs=[{
"role_ids": [quality_role_id],
"message": "Capacitor lot received, inspect before use.",
"inventory_id": event.subject_id,
}])Hooks run inside the change that triggered them. A hook that calls fail("...") refuses that change: nothing it did is saved, and the person who made it sees the message, or an API caller gets it as a 400 error.
Events generated by hook writes trigger matching hooks in the next round. Each round batches events by event type and actor; a hook can run again when a later write triggers it. All rounds run in the original transaction. A downstream hook failure rolls back the original change and every chained database write. HTTP requests already sent cannot be rolled back.
A transaction allows up to 32 hook rounds and 100 hook invocations. Exceeding either limit refuses the change with an error. Avoid cycles where hooks keep triggering one another.
Field-change hooks
Subscribe with <entity>_cf_<field_key>, for example inventory_cf_torque. Supported entities are inventory, work_orders, users, and tasks (work-order tasks). subject_id identifies the owning record; inventory uses the inventory group UUID. Hooks run on first entry, changes, and clearing, but not unchanged values. Field configuration changes and lifecycle copies do not trigger field-change hooks. UI, API, and plugin writes use the same hooks.
yaml
hooks:
- event: inventory_cf_torque
file: hooks/validate_torque.pypython
def main(args):
for event in args:
previous = event.data["old_value"]
current = event.data["new_value"]
if current == None:
fail("Torque cannot be cleared.")Every event has an immutable data dictionary. Existing events use {}. Field-change data contains entity_type, field_id, field_key, old_value, and new_value. Text, numbers, dates, and date & time values are strings; numbers preserve decimal precision. Checkboxes are booleans, select values are option UUID strings, and multiselect values are lists of option UUID strings. Unanswered values are None, except checkboxes (False) and multiselects ([]).
Renaming a field key updates its subscriptions atomically across all uploaded plugin versions, including drafts and disabled versions. Script source and local manifests are not rewritten. Update local manifests before uploading again; unknown field keys are rejected. A field subscription can be registered before placing that field on the entity type.
Hook fixtures accept either subject-ID strings or objects with subject_id and data. For example, use this fixture input to test a cleared number:
json
[{"subject_id": "EXISTING-INVENTORY-GROUP-UUID", "data": {
"entity_type": "inventory", "field_id": "EXISTING-FIELD-UUID",
"field_key": "torque", "old_value": "10", "new_value": null
}}]Fixture objects use the uploaded subscription as the event name. Fixtures run the script with supplied data; they do not perform the triggering field write.
Querying Standard Fields
Include standard fields when querying inventory, work orders, or users. Values are returned in a dictionary keyed by the field definition’s key:
python
def main(args):
page = query(entity=qn.inventory, include=[include.inventory.fields])
for item in page.data:
program = item["fields"].get("customer_program")
print(program)Use include.work_orders.fields or include.users.fields for the other supported types. Follow page.next when querying additional pages. Loading fields batches the values for the entire page.
Text, numbers, dates, and date & time values are strings; numbers retain their decimal precision. Checkboxes are booleans. Select values are option UUIDs, and multiselect values are lists of option UUIDs, so renaming an option does not break comparisons. Unanswered values are None, except unchecked checkboxes (False) and empty multiselects ([]). Without the include, fields and field_metadata are None.
item["field_metadata"]["customer_program"] contains field_id, type, and archived. Archived values remain readable but cannot be edited.
Standard fields support query filters on inventory, work orders, and users, including through relationships. Identify a field with cf_ followed by its key: for example, {"f":"cf_pedigree","o":"IN","v":["1","2"]} matches either option label. Select and multiselect filters use option labels, while included field values return option UUIDs. See Filtering by fields for operators and unanswered-value behavior.
To write values, call factorial.entity_fields.set(inputs=[...]). Each input identifies entity_type (inventory, work_orders, or users), entity_id, and field_key, plus exactly one of value, checked, or option_ids. Inventory IDs are inventory group IDs, the same IDs returned by queries. Use an empty value or option_ids list to clear a value. A batch must use one entity type and cannot write the same field on the same record twice. Writes require the same permissions as editing values in the app.
python
factorial.entity_fields.set(inputs=[{
"entity_type": "inventory",
"entity_id": inventory_id,
"field_key": "customer_program",
"value": "Corvane witness testing",
}])Standard-field writes dispatch matching field-change hooks.
Receivers
Receiver scripts register an endpoint at yoursubdomain.factorialhq.com/receivers/{plugin-name}/{endpoint}, and main(args) is invoked when that endpoint receives a POST request. args contains the request body. This can be useful for using Factorial as a receiver of webhooks. For example to sync parts from a PLM in realtime, the PLM may have a webhook where it sends the part information to a configurable endpoint every time a new part is created. The receiver script would then be invoked with the payload from the POST request and create the part in Factorial. The request is automatically authenticated and API keys for the rest of the Factorial API are valid for these receiver endpoints.
Jobs
Jobs run a script periodically on a certain schedule. Factorial invokes main(args) with args set to None. Jobs can import data from other systems, export Factorial data, close stale tasks, or send reminders.
Set a job's schedule in plugin.yaml to one of the following values:
every_15_minevery_2_hoursevery_4_hoursevery_8_hoursevery_1_dayevery_7_days
Actions
Actions are invoked through slash commands to the command bar anywhere in the app. An action script has a configured input schema, which is a set of typed fields. When a user invokes the slash command, a modal appears with the fields they must enter, and Factorial invokes main(args) with the submitted values. The script is run with the permissions of the user who invoked it. This can help automate routine tasks, for example, if a certain rework process requires scheduling three separate work orders, a slash command could be written that collects the inventory that needs work, and an assignee, and schedules all three work orders in one command.
Issues
Plugin support for issues is planned. Plugins will be able to raise issues, link affected records, and place holds, for example from a field-change hook when a recorded value falls outside an acceptable range. Hooks will run when issues are created and changed and when their dispositions are reviewed and applied, and a hook will be able to require customer authorization on a disposition.
Testing Scripts
All script types support running them manually with a mocked payload, side effects will be listed in the execution so you can see what they would have done if enabled. External requests are not run by default.
Execution Logs
Script execution logs are available in app for debugging purposes. Detailed log execution data is only maintained for the last 30 days by default.
AI Integration
Our CLI can create plugins itself, empowering AI agents to read/write/edit plugins. A plugin-writing skill is available in our open-source skills repository which helps make agents more successful when writing scripts for Factorial. All script-writing pages also have a "Copy Narkdown" button that delivers relevant skill guidance, documentation, existing code, and types into one prompt that can be easily transported and pasted into an AI conversation.
Example Use Case: Pedigree
COMING SOON
Reference
Coming Soon