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, factorial: the
Trigger from Factorial section of the process page in the console, or the
factorial key of processes/<slug>/metadata.json (round-tripped by
fcode pull / fcode push, field reference in fcode-cli).
What is live today and what is not
| Available now | Still to come |
|---|---|
The factorial block in the console, in metadata.json, and as settings.factorial in the API |
Factorial One tools: the assistant reading the agentTool 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 form.public; retiring what is left of form.appRole |
requiredPolicies enforced on every invocation, before anything runs |
|
uiTrigger: the button lists and runs over this contract, addressed by an opaque action_id — no process slug reaches the browser |
|
form: a form opened from Factorial submits through Factorial's backend, which carries the user's identity and injects the trusted context |
|
File parameters (x-fcode-file) uploaded straight to Factorial Code storage with a grant, the submission carrying the fcode.storage:// path |
|
uiTrigger.label localized with fcode.i18n("key") tokens on the ?locale= listing |
|
lifecycleRole derived from the reserved slugs and tagged in the console |
So today uiTrigger, form, requiredPolicies and awaitResult all do
something. agentTool and backend 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
enabledis a master switch. While it isfalsenothing below is exposed, whatever each entry point's ownenabledsays. Turning it back on restores the entry points exactly as they were.awaitResultdefaults totrue(synchronous). The legacyuiTriggerdefaulted to fire-and-forget. The mirror copiesfactorial.awaitResultintouiTrigger.awaitResult, so a button moved to thefactorialblock becomes synchronous unless you write"awaitResult": false. Keep synchronous actions under the ~50 s budget (fcode-ui-triggers).requiredPoliciesare 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
[["a"], []]still requiresa. - Only
uiTrigger.labelis translatable. It is the one text users see, so it takesfcode.i18n("key")tokens (fcode-i18n). TheagentTooltexts are for the model — plain text, one language, never shown to users. The process's ownname/descriptionserve humans and the marketplace card; the block has no title of its own. - An unset
effectis treated asDESTRUCTIVE. Every tool fails closed (approval + "cannot be undone" warning) until you sayREADorWRITE. - The four lifecycle slugs are reserved.
install,settings,uninstall,syncmean something to Factorial regardless of the block. Do not give an ordinary action one of them. - Omit the defaults.
fcode pullleaves outawaitResultwhentrue,requiredPolicieswhen empty,form.publicandform.appToolwhenfalse, unsetagentTooltexts and empty lists, and any sub-block whoseenabledisfalse. A process not exposed to Factorial has nofactorialkey at all. A partial update leaves the unnamed fields unchanged. - Legacy
form/uiTriggerkeys still round-trip. When a file carries both,fcode pushsendsfactorialand lets the mirror update the rest. Prefer writingfactorial(below).
The factorial block, field by field
{
"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 |
|---|---|---|
enabled |
boolean, false |
Master switch for every entry point below |
awaitResult |
boolean, true |
true: Factorial waits and receives the process's return value unchanged (fcode-ui-triggers). false: Factorial only learns the execution started; the process reports back itself (FactorialClient, notification). Use false for imports and bulk work |
requiredPolicies |
string[][], [] |
OR of AND-groups of Factorial policy keys the user must hold: [["a","b"],["c"]] 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 |
uiTrigger.enabled |
boolean | Render the action as a button inside Factorial |
uiTrigger.locationId |
string | Factorial UI location the button renders at (e.g. calendar.header.admin). Required while enabled, ≤ 200 chars, given by the Factorial team owning the page — see fcode-ui-triggers for the location contract |
uiTrigger.label |
string | Button text. The only i18n field: plain text or fcode.i18n("key") tokens |
uiTrigger.icon |
string | Allowlisted icon name (fcode-ui-triggers); omit for a text-only button |
form.enabled |
boolean | Expose the parameters as a form (fcode-forms). Same switch and same quota as the Forms flag |
form.appTool |
boolean, false |
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 false 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 |
form.public |
boolean, false |
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 |
agentTool.enabled |
boolean | Let the Factorial One assistant call the process as a tool. The rest of the sub-block is the tool contract (next section) |
backend.enabled |
boolean | Let Factorial backend jobs run the process with no user in front of it |
The Factorial One tool contract (agentTool)
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 |
|---|---|---|
description |
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 |
effect |
READ | WRITE | DESTRUCTIVE |
READ runs without asking; WRITE asks for approval; DESTRUCTIVE asks and warns it cannot be undone. Unset ⇒ DESTRUCTIVE |
whenToUse |
string | The situation the tool is meant for, phrased as a user would ask |
whenNotToUse |
string[] | Look-alike situations that are not a match, one per entry — name the neighbour tool to use instead |
preconditions |
string[] | What must already be true before calling, one per entry; the assistant checks or asks instead of calling and failing |
doesNotDo |
string[] | Side effects a user might assume and the tool does not have, one per entry |
degradation |
string | What happens on partial success: what is done, what is skipped, how the result says so |
simulatesFor |
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
fcode.i18n(...)tokens here. description: object + outcome. "Approves every pending time-off request of a team" beats "Time-off tool".- One situation in
whenToUse. If you need "or", you probably have two tools. Phrase it the way the user would ask. - Name neighbour tools in
whenNotToUse. "The user wants a single request approved: useapprove-timeoff-request." Each entry is one situation. preconditionsare checkable facts ("the team has at least one pending request"), not advice.doesNotDolists what a user would assume. Notifications, side writes, undo — say what is not happening so the assistant can offer the missing step.degradationdescribes the partial outcome, so the assistant can explain it instead of reporting a failure.- A dry-run process points at its real one with
simulatesForand is itselfREAD; the real one isWRITEorDESTRUCTIVE.
Reserved lifecycle slugs
| Slug | Role | What the platform does |
|---|---|---|
install |
The form Factorial shows when a company installs the App | Derives lifecycleRole: INSTALL |
settings |
The form an installed company uses to configure the App | SETTINGS |
uninstall |
Runs when the company removes the App | UNINSTALL |
sync |
The sync process of an integrations-framework App | SYNC |
lifecycleRole 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 metadata.json and not in the content hash). install, settings and
uninstall are the marketplace lifecycle forms; they still take
factorial.form.enabled like any form. The pre-render pattern for re-opened
install/settings forms is in fcode-forms.
Parameters: the contract with Factorial
Whatever the entry point, what Factorial sends is described by the process's
parametersSchema.json (fcode-json-schema). The form renders it, the
assistant reads it to know what the tool takes, Factorial validates against it.
Give every parameter a title and a description, list the mandatory ones in
required, and treat any schema change as a change to what Factorial may
call you with. Trusted keys a UI trigger injects (company_id,
triggered_from_location, access_id) are described in fcode-ui-triggers.
The transition from form and uiTrigger
The factorial block supersedes the Forms flag (form) and the UI
trigger button (uiTrigger). Both stay for now and the platform keeps them
mirrored: whichever side you write, the other is updated, last write wins.
Writing factorial… |
…updates the legacy setting |
|---|---|
enabled && uiTrigger.enabled |
uiTrigger.enabled |
uiTrigger.locationId, uiTrigger.icon, uiTrigger.label |
the same keys of uiTrigger |
awaitResult |
uiTrigger.awaitResult |
enabled && form.enabled |
form.enabled |
form.appTool |
form.appRole = USER_FACING_FORM |
Writing the legacy blocks mirrors the same fields back, and factorial.enabled
turns on as soon as any entry point is enabled.
form.appRole is the one legacy field the block partly owns. Of its four
values, INSTALL / SETTINGS / UNINSTALL come from the reserved slugs and
are left untouched, while USER_FACING_FORM is form.appTool — turning the
flag on claims that value, turning it off releases it only if it was held, and
a write that says nothing about appTool leaves the role alone. So a file
written before the flag existed keeps its role on push: the CLI reads
appRole and sends the flag to match.
Prefer factorial: write it in new processes, and when touching an old
metadata.json, move the settings into it rather than editing uiTrigger.
Worked example — SILTRA FIE import (dry run + apply)
Adapted from the siltra-integration-app: fie-import reads the .msj file
a company downloads from SILTRA and reports which sick leaves it would
create, link or close in Factorial, writing nothing; fie-import-apply does the
writing, in the background.
processes/fie-import/metadata.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:
{
"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 fcode.storage://… reference — fcode-forms).
Checklist before shipping
factorial.enabledon, and only the entry points you mean to expose enabled.awaitResultdecided explicitly:falsefor anything slow; a synchronous action returns a shape the entry point it runs under can read.requiredPolicieskeys copied from Factorial's catalogue, not guessed — or deliberately empty, which leaves the action open to anyone who reaches it.agentTool:effectset,whenNotToUsenames the neighbour tools, dry runs point at their real process withsimulatesFor.- No ordinary action uses
install,settings,uninstallorsyncas slug; the schema has titles, descriptions andrequired.