Factorial Code — Factorial Actions
A Factorial Action is a process that the customers who installed an App
reach from inside Factorial, without knowing Factorial Code exists. One
process is one action, and the same action can be exposed through several
entry points at once — a button in the Factorial UI, a form, a Factorial One
tool, a backend job. Whatever the entry point, Factorial sends the process its
parameters and, when asked to, waits for the result; the process does not know
which entry point started it, so one implementation serves all of them.
Everything an action needs lives in
one settings block, : the
Trigger from Factorial section of the process page in the console, or the
key of
processes/<slug>/metadata.json
(round-tripped by
/
, field reference in
).
What is live today and what is not
| Available now | Still to come |
|---|
| The block in the console, in , and as in the API | Factorial One tools: the assistant reading the contract and calling the process |
| The Actions API Factorial invokes through: list a company's actions, read an action's form schema, invoke it, issue upload grants, and list an app's declared actions before it is installed | Enforcing ; retiring what is left of |
| enforced on every invocation, before anything runs | |
| : the button lists and runs over this contract, addressed by an opaque — no process slug reaches the browser | |
| : a form opened from Factorial submits through Factorial's backend, which carries the user's identity and injects the trusted context | |
| File parameters () uploaded straight to Factorial Code storage with a grant, the submission carrying the path | |
| localized with tokens on the listing | |
| derived from the reserved slugs and tagged in the console | |
So today
,
,
and
all do
something.
and
are stored and reachable, but no assistant
reads a tool contract yet — fill them in now so the action is ready when it
does.
Gotchas
- is a master switch. While it is nothing below is
exposed, whatever each entry point's own says. Turning it back on
restores the entry points exactly as they were.
- defaults to (synchronous). The legacy
defaulted to fire-and-forget. The mirror copies into
, so a button moved to the block becomes
synchronous unless you write . Keep synchronous
actions under the ~50 s budget ().
- are enforced, but the keys are free text. Factorial
checks them before it runs anything. It does not check that a key exists: one
it does not recognise is simply never granted, so the whole AND-group
containing it fails — a typo can only close an action, never open one. Take
keys from Factorial's policy catalogue; prefer several small alternatives over
one long group.
- No policies means open. An empty list, or no list at all, lets anyone who
can reach the entry point run the action — the entry point's own gate (a
button's location permissions, an installed app) is all that remains. An empty
group inside a non-empty list is dropped rather than treated as "allow all",
so still requires .
- Only is translatable. It is the one text users see, so
it takes tokens (). The texts are
for the model — plain text, one language, never shown to users. The process's
own / serve humans and the marketplace card; the block
has no title of its own.
- An unset is treated as . Every tool fails closed
(approval + "cannot be undone" warning) until you say or .
- The four lifecycle slugs are reserved. , ,
, mean something to Factorial regardless of the block. Do
not give an ordinary action one of them.
- Omit the defaults. leaves out when ,
when empty, and when ,
unset texts and empty lists, and any sub-block whose is . A process
not exposed to Factorial has no key at all. A partial update
leaves the unnamed fields unchanged.
- Legacy / keys still round-trip. When a file carries
both, sends and lets the mirror update the rest.
Prefer writing (below).
The block, field by field
json
{
"factorial": {
"enabled": true,
"awaitResult": false,
"requiredPolicies": [["company.manage_timeoff"], ["company.admin"]],
"uiTrigger": { "enabled": true, "locationId": "calendar.header.admin", "label": "fcode.i18n(\"acme.approve.button\")", "icon": "Bell" },
"form": { "enabled": true, "public": false, "appTool": true },
"agentTool": { "enabled": true, "description": "…", "effect": "WRITE" },
"backend": { "enabled": true }
}
}
| Field | Type / default | Meaning |
|---|
| boolean, | Master switch for every entry point below |
| boolean, | : Factorial waits and receives the process's return value unchanged (). : Factorial only learns the execution started; the process reports back itself (FactorialClient, notification). Use for imports and bulk work |
| , | OR of AND-groups of Factorial policy keys the user must hold: reads (a and b) or c. Empty = anyone who reaches the entry point. Console: one alternative per line, commas between the keys of a group |
| boolean | Render the action as a button inside Factorial |
| string | Factorial UI location the button renders at (e.g. ). Required while enabled, ≤ 200 chars, given by the Factorial team owning the page — see for the location contract |
| string | Button text. The only i18n field: plain text or tokens |
| string | Allowlisted icon name (); omit for a text-only button |
| boolean | Expose the parameters as a form (). Same switch and same quota as the Forms flag |
| boolean, | Offer the form to the company's users on the App's own page in Factorial, among the things the App lets them run — a "Report a sync issue" or "Sync now" form. Leave it for a form the App opens itself. This is the block's name for the legacy form.appRole: USER_FACING_FORM
, which it is kept mirrored with |
| boolean, | Deprecated. Declares the form may also be embedded anonymously outside Factorial, as the legacy embed does. Stored, not enforced yet; set it only on forms that genuinely need anonymous access |
| boolean | Let the Factorial One assistant call the process as a tool. The rest of the sub-block is the tool contract (next section) |
| boolean | Let Factorial backend jobs run the process with no user in front of it |
The Factorial One tool contract ()
The parameters schema tells the assistant what the tool takes; this block
tells it what the tool is for. Plain text, no i18n.
| Field | Type | What it buys the model |
|---|
| string | What the tool does, one or two sentences: name the object it acts on and the outcome. This is what user requests are matched against |
| | | | runs without asking; asks for approval; asks and warns it cannot be undone. Unset ⇒ |
| string | The situation the tool is meant for, phrased as a user would ask |
| string[] | Look-alike situations that are not a match, one per entry — name the neighbour tool to use instead |
| string[] | What must already be true before calling, one per entry; the assistant checks or asks instead of calling and failing |
| string[] | Side effects a user might assume and the tool does not have, one per entry |
| string | What happens on partial success: what is done, what is skipped, how the result says so |
| string | Slug of the process this one previews (a dry run of it). Free text, not validated yet |
Writing good contract texts
- Plain language, no i18n, one language. Write for a model reading English,
not for a screen. Never put tokens here.
- : object + outcome. "Approves every pending time-off request
of a team" beats "Time-off tool".
- One situation in . If you need "or", you probably have two
tools. Phrase it the way the user would ask.
- Name neighbour tools in . "The user wants a single request
approved: use ." Each entry is one situation.
- are checkable facts ("the team has at least one pending
request"), not advice.
- lists what a user would assume. Notifications, side writes,
undo — say what is not happening so the assistant can offer the missing
step.
- describes the partial outcome, so the assistant can explain
it instead of reporting a failure.
- A dry-run process points at its real one with and is
itself ; the real one is or .
Reserved lifecycle slugs
| Slug | Role | What the platform does |
|---|
| The form Factorial shows when a company installs the App | Derives |
| The form an installed company uses to configure the App | |
| Runs when the company removes the App | |
| The sync process of an integrations-framework App | |
is
read-only and derived from the slug: the console shows a
tinted
Install / Settings / Uninstall / Sync tag in the processes list and
the process header, the CLI and API report it, nothing ever writes it (it is not
in
and not in the content hash).
,
and
are the marketplace lifecycle forms; they still take
like any form. The pre-render pattern for re-opened
install/settings forms is in
.
Parameters: the contract with Factorial
Whatever the entry point, what Factorial sends is described by the process's
(
). The form renders it, the
assistant reads it to know what the tool takes, Factorial validates against it.
Give every parameter a
and a
, list the mandatory ones in
, and treat any schema change as a change to
what Factorial may
call you with. Trusted keys a UI trigger injects (
,
,
) are described in
.
The transition from and
The
block supersedes the
Forms flag (
) and the
UI
trigger button (
). Both stay for now and the platform keeps them
mirrored: whichever side you write, the other is updated, last write wins.
| Writing … | …updates the legacy setting |
|---|
enabled && uiTrigger.enabled
| |
| , , | the same keys of |
| |
| |
| = |
Writing the legacy blocks mirrors the same fields back, and
turns on as soon as any entry point is enabled.
is the one legacy field the block
partly owns. Of its four
values,
/
/
come from the reserved slugs and
are left untouched, while
is
— turning the
flag on claims that value, turning it off releases it only if it was held, and
a write that says nothing about
leaves the role alone. So a file
written before the flag existed keeps its role on push: the CLI reads
and sends the flag to match.
Prefer : write it in new processes, and when touching an old
, move the settings into it rather than editing
.
Worked example — SILTRA FIE import (dry run + apply)
Adapted from the
:
reads the
file
a company downloads from SILTRA and
reports which sick leaves it would
create, link or close in Factorial, writing nothing;
does the
writing, in the background.
processes/fie-import/metadata.json
:
json
{
"name": "FIE Import",
"description": "Reads the .msj file the company downloads from SILTRA and shows what importing it would change in Factorial. Writes nothing — the import only happens after confirmation.",
"tags": ["inbound", "siltra", "fie"],
"factorial": {
"enabled": true,
"requiredPolicies": [["company.manage_timeoff"], ["company.admin"]],
"uiTrigger": {
"enabled": true,
"locationId": "calendar.header.admin",
"label": "fcode.i18n(\"calendar.import.fie.button\")",
"icon": "Upload"
},
"form": { "enabled": true },
"agentTool": {
"enabled": true,
"description": "Reads a SILTRA FIE transmission (.msj) and reports which sick leaves it would create, link or close in Factorial, and which events it would leave out and why. It writes nothing.",
"effect": "READ",
"whenToUse": "The user has downloaded a FIE file from SILTRA and wants to know what importing it would change before anything is written.",
"whenNotToUse": [
"The user has already reviewed the report and wants the leaves written: use fie-import-apply.",
"The file is not a SILTRA .msj transmission."
],
"preconditions": ["The .msj file has been uploaded and its storage reference is passed as msj_file."],
"doesNotDo": ["It does not create, change or close any leave.", "It does not notify employees or managers."],
"degradation": "If Factorial cannot be consulted, the report still lists every event in the file, marked as not compared against Factorial.",
"simulatesFor": "fie-import-apply"
}
}
}
processes/fie-import-apply/metadata.json
— no button, no form, asynchronous,
and the write side of the pair:
json
{
"name": "FIE Import — apply",
"tags": ["inbound", "siltra", "fie"],
"factorial": {
"enabled": true,
"awaitResult": false,
"requiredPolicies": [["company.manage_timeoff"], ["company.admin"]],
"agentTool": {
"enabled": true,
"description": "Writes the sick leaves a SILTRA FIE transmission describes into Factorial: creates, links or closes them.",
"effect": "WRITE",
"whenToUse": "The user has seen the fie-import report for a file and asks to apply it.",
"whenNotToUse": ["The user only wants to know what the file would change: use fie-import."],
"preconditions": ["fie-import has reported on this same file and the user confirmed the result."],
"doesNotDo": ["It does not send SILTRA anything back.", "It does not notify employees."],
"degradation": "Leaves that fail to write are skipped and listed in the result; the rest are applied."
},
"backend": { "enabled": true }
}
}
The policy keys are illustrative — take the real ones from Factorial's policy
catalogue. Read the parameters as usual (
fcode.context.parameters.msj_file
,
an
reference —
).
Checklist before shipping
- on, and only the entry points you mean to expose enabled.
- decided explicitly: for anything slow; a synchronous
action returns a shape the entry point it runs under can read.
- keys copied from Factorial's catalogue, not guessed — or
deliberately empty, which leaves the action open to anyone who reaches it.
- : set, names the neighbour tools, dry runs
point at their real process with .
- No ordinary action uses , , or as slug;
the schema has titles, descriptions and .