Factorial Actions
A Factorial Action is a process that the customers who installed your 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:
| Entry point | What the customer sees | Setting |
|---|---|---|
| Button in the Factorial UI | A button rendered at a registered location of the Factorial product, with your label and icon. | uiTrigger |
| Form | The process input parameters rendered as a form, opened from Factorial or embedded in a web page. | form |
| Factorial One tool | The Factorial One assistant may call the process as a tool, following the tool contract you write for it. | agentTool |
| Backend job | Factorial’s own backend runs the process, with no user in front of it. | backend |
Whatever the entry point, Factorial sends the process its parameters and, when asked to, waits for
the result. One implementation can serve all of them; when it needs to know which company it runs
for, or which entry point started it, it reads fcode.context.factorial.
Everything an action needs lives in one settings block, factorial. In the console it is the
Trigger from Factorial section of the process Dashboard; in code
it is the factorial key of processes/<slug>/metadata.json, which travels with
fcode pull and fcode push.
The settings block
Section titled “The settings block”{ "factorial": { "enabled": true, "awaitResult": false, "requiredPolicies": [["company.manage_timeoff"], ["company.admin"]], "uiTrigger": { "enabled": true, "locationId": "calendar.header.admin", "label": "fcode.i18n(\"actions.approve.button\")", "icon": "Bell" }, "form": { "enabled": true, "public": true, "appTool": true }, "agentTool": { "enabled": true, "description": "Approves every time-off request still pending for a team.", "effect": "WRITE", "whenToUse": "The user asks to approve, accept or clear the pending time-off requests of a team.", "whenNotToUse": ["The user wants to approve a single, named request."], "preconditions": ["The team has at least one pending request."], "doesNotDo": ["It does not notify the employees whose requests were approved."], "degradation": "If some requests fail to approve, the rest are still approved and the failures are listed in the result.", "simulatesFor": "" }, "backend": { "enabled": true } }}enabled: the master switch. While it is off nothing below is exposed, whatever the switch of each entry point says. Turning it back on restores the entry points exactly as they were.awaitResult: whether Factorial waits for the execution to finish. Defaults totrue. See waiting for the result.requiredPolicies: the Factorial permissions a user needs to run the action. Empty means no restriction. See who may run the action.uiTrigger.enabled: render the action as a button inside Factorial.uiTrigger.locationId: identifier of the place in the Factorial UI the button is rendered at, such ascompensations.cycle.header. Required while the button is enabled, at most 200 characters; the locations are registered by Factorial and are outside this documentation.uiTrigger.label: the text on the button. The only text in the block that is shown to Factorial’s users, and the only one that accepts translation tokens (see translating the button label).uiTrigger.icon: the name of the icon shown on the button, from Factorial’s own icon set — the console offers a searchable picker.form.enabled: expose the process parameters as a form. This is the same switch the forms feature has always had, and the same forms quota applies.form.public: whether the form may also be opened anonymously, outside Factorial, as the legacy embed does. See public forms.form.appTool: whether the App offers the form to its users from its own page in Factorial. See forms offered on the app page.agentTool.enabled: let the Factorial One assistant run the process as a tool. The rest of the sub-block is the tool contract the assistant reads; see the Factorial One tool contract.backend.enabled: let Factorial backend jobs run the process.
Fields you leave out keep their defaults, and an update that names only some fields leaves the
others unchanged, so a metadata.json that carries only what differs from the defaults is
complete.
The block has no title or description of its own: the process’s name and description are what humans and the marketplace card see, and the assistant reads the tool contract below.
The Factorial One tool contract
Section titled “The Factorial One tool contract”When agentTool.enabled is on (and the block’s master enabled switch too), the Factorial One
assistant sees the process as a tool it may call on behalf of the user of a company that installed
your App. It gets two things from you, and nothing else about the process:
- the tool contract — every field of the
agentToolsub-block below, as you wrote it. This is what the assistant decides with: whether this tool fits the request, how careful to be, what to check first and what to tell the user afterwards. - the input parameters schema, without its presentation keys — what the tool takes.
The assistant cannot read your source code or ask you what you meant, so the contract is the whole
of what it knows about the tool’s purpose. Write it in plain text, in one language; it is never
shown to end users. A tool with only a description still works, but the assistant has to guess
everything the other fields would have told it.
| Field | Type | What it buys the model |
|---|---|---|
description | string | What the tool does, in one or two sentences. This is what the assistant matches a user’s request against, so name the object it acts on and the outcome. |
effect | READ · WRITE · DESTRUCTIVE | How careful the assistant must be. READ runs without asking; WRITE asks the user for approval first; DESTRUCTIVE asks for approval and warns that the result cannot be undone. Unset is treated as DESTRUCTIVE, so every tool fails closed until you say otherwise. |
whenToUse | string | The situations the tool is meant for, phrased the way a user would ask. It helps the assistant pick this tool over a similar one. |
whenNotToUse | string[] | Situations that look like a match but are not, one per entry. It stops the assistant from reaching for the tool when a narrower one, or no tool, is right. |
preconditions | string[] | What must already be true before calling, one per entry. The assistant checks or asks for these before running rather than calling and failing. |
doesNotDo | string[] | Side effects a user might assume and the tool does not have, one per entry. The assistant will not promise them, and can offer the missing step separately. |
degradation | string | What happens when the tool can only partly succeed — what is done, what is skipped, how the result says so. It lets the assistant explain a partial outcome instead of reporting a failure. |
simulatesFor | string | The slug of the process of the same App that this one previews — a dry run whose result shows what that other tool would do. The assistant can offer the preview before the real, approval-gated call, so expose both as tools. The assistant is told which action is previewed, never the slug itself. It is not checked: a slug that names no process of the App points the assistant at an action that does not exist. |
Empty lists and unset strings are omitted from metadata.json. Whatever is left out is simply
unknown to the assistant; only effect has a default, and it is the most restrictive one.
Translating the button label
Section titled “Translating the button label”uiTrigger.label is shown to the customer’s users, in their own language, so it accepts the same
fcode.i18n("key") tokens a form schema
does:
{ "factorial": { "enabled": true, "uiTrigger": { "enabled": true, "locationId": "calendar.header.admin", "label": "fcode.i18n(\"actions.approve.button\")" } }}The platform resolves the token in the language of the user the button is shown to, following the usual regional variant and primary locale fallbacks, so a key you have not translated shows up as the key itself rather than breaking the button. Plain text works too, in one language. Nothing else in the block is translated: the tool contract is for the assistant, and the process name and description are localized where the process is, not here.
Waiting for the result
Section titled “Waiting for the result”awaitResult decides what Factorial gets back when it starts the action.
-
true, the default: synchronous. Factorial waits for the execution and receives what the process returns. Return{ "data": { ... } }for a success or{ "errors": [{ "code": "...", "message": "..." }] }for a failure Factorial should show to the user. Keep these actions short: a user is usually waiting behind a button or a form.An answer your process gives with an error status — such as
{ status: 400, body: { formErrors: … } }for inline form errors — reaches Factorial as your answer, with the body as you returned it, field-levelformErrorsincluded. It is not mistaken for Factorial having sent bad parameters. -
false: asynchronous. Factorial only learns that the execution started and moves on. The process is then responsible for reporting back — writing to Factorial through the FactorialClient, sending a notification, or leaving the outcome where the customer will look for it. Use it for long-running work such as imports and bulk updates, which would otherwise time out.
The same flag drives the legacy UI trigger button (see the transition).
The flag belongs to the process and applies to every entry point it is exposed on; there is no per-entry-point setting. An action that must answer a button synchronously and run as a long background job elsewhere is two processes.
Who may run the action
Section titled “Who may run the action”requiredPolicies names the Factorial policy keys a user must hold to run the action. It is a
list of alternatives, and each alternative is a list of keys that must all hold — an OR of AND
groups:
"requiredPolicies": [ ["company.manage_timeoff", "company.view_reports"], ["company.admin"]]reads as (manage time off and view reports) or admin. An empty list, or no list at all, means anyone who can reach the entry point may run the action. In the console the field is a text area: one alternative per line, commas between the keys that must all hold.
Factorial Code stores the keys as you write them and does not check them against Factorial. A key Factorial does not recognise is simply never granted, so the alternative containing it fails — it cannot open the action to anyone. Take the keys from Factorial’s policy catalogue, and prefer several small alternatives to one long group.
Reserved lifecycle slugs
Section titled “Reserved lifecycle slugs”Four process slugs mean something to Factorial regardless of how the process is configured:
| Slug | Role |
|---|---|
install | The form Factorial shows when a company installs the App. |
settings | The form an installed company uses to configure the App. |
uninstall | Runs when the company removes the App. |
sync | The sync process of an integrations-framework App. |
install, settings and uninstall are the marketplace lifecycle forms; sync is the
integrations sync process. The platform derives the role from the slug and exposes it read-only
as the process’s lifecycleRole: the console tags these processes in the processes list and in
the process header, and the CLI and API report the role but never write it. Do not give an
ordinary action one of these slugs.
Public forms
Section titled “Public forms”A form exposed through form is meant to be opened from Factorial, by a user Factorial has already
authenticated. form.public declares that the form may also be embedded anonymously in any web
page, the way the legacy embed has always worked.
The flag is stored but not enforced yet: today the Authentication field described in
restricting who can open the form
still decides who may open a form, and form.public changes nothing on its own. Set it now to
record which of your forms genuinely need anonymous access; when enforcement lands, forms without
it will require a Factorial user.
Forms offered on the app page
Section titled “Forms offered on the app page”An installed App has a page of its own in Factorial. form.appTool puts the form on it, among the
things the App offers the people of the company that installed it — a “Report a sync issue” form, a
“Sync now” form. Leave it off for a form the App opens itself, from a button or a link of its own.
This is the same setting as the USER_FACING_FORM app role the legacy
form block carries, under the name the factorial block gives it; the platform keeps the two
mirrored (see the transition).
The reserved lifecycle slugs are not app tools: install, settings
and uninstall are forms Factorial opens at a moment of its own choosing, and their role comes
from the slug.
The transition from form and uiTrigger
Section titled “The transition from form and uiTrigger”The factorial block supersedes two older settings blocks, the forms flag (form) and the
UI trigger button (uiTrigger). Both stay for now, and the platform keeps them mirrored
with the new block: whichever of the two sides you write, the other is updated to match, and the
last write wins. You can keep editing form and uiTrigger from an old metadata.json or through
the API, and see the result in the Trigger from Factorial section — and vice versa.
Writing factorial… | …updates the legacy setting |
|---|---|
enabled && uiTrigger.enabled | uiTrigger.enabled |
uiTrigger.locationId, uiTrigger.icon | uiTrigger.locationId, uiTrigger.icon |
uiTrigger.label | uiTrigger.label |
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 becomes true as
soon as any entry point is enabled. form.authMode is not part of the new block and is left
untouched. form.appRole holds one value the new block owns — USER_FACING_FORM, which is
form.appTool — and keeps the rest: turning appTool off releases the role only when it was
USER_FACING_FORM, so a lifecycle role is never dropped, and a write that does not mention
appTool leaves the role alone. When a metadata.json carries both factorial and the legacy
blocks, fcode push sends factorial and lets the mirror take care of the rest — including
form.appRole when it is USER_FACING_FORM, which travels as form.appTool instead. A file
written before appTool existed still works: the CLI reads the role and sends the flag to match.
Parameters: the contract with Factorial
Section titled “Parameters: the contract with Factorial”Whatever the entry point, what Factorial sends to the process is described by the process’s
input parameters schema. That schema is the contract: the
form renders it, the assistant reads it to know what the tool takes, and Factorial validates what
it sends against it. Give every parameter a title and a description, mark the mandatory ones as
required, and treat any change to the schema as a change to what Factorial may call you with.
The Factorial One assistant is given the schema without its presentation keys — ui,
uiSchema, markdown, embedFormOptions and the like — since it does not render a form. Whatever
it needs to call the tool correctly has to be in the data keys: type, title, description,
enum, required, and x-fcode-file for files.
A pre-render process runs whoever asks for the schema, the assistant included: it belongs to the process, not to the entry point. If the assistant must not see what the pre-render adds, expose it through a second process that has no pre-render.
File parameters
Section titled “File parameters”A parameter that takes a file is declared with
x-fcode-file, listing the
extensions it takes in accept:
"msj_file": { "title": "FIE file", "type": "string", "x-fcode-file": { "accept": [".msj"] }, "ui": { "ui:widget": "file", "ui:options": { "accept": ".msj" } }}Factorial uploads a file for an action only to a parameter declared this way, only through an
entry point the action is exposed on, and only when the file name matches accept.
Declaring a file parameter tells the assistant that this parameter takes a file rather than text,
and accept tells it which files: name the extensions. A parameter that really takes any file
declares "accept": [].
A property that only carries "ui:widget": "file" is still treated as a file for now, through a
deprecated fallback that will be removed; fcode push warns about each one. The widget stays: the
form needs it to draw a file picker.
Files uploaded for an action are removed once they are 48 hours old, whatever the upload asked for. The cleanup runs every 24 hours, so a file actually lives between 48 and 72 hours; count on 48. That covers the slowest path from upload to your process reading the file: an hour to upload, up to a day queued, up to 12 hours executing. If the process needs a file for longer, copy it elsewhere in storage with the lifetime it needs.
Who invoked the action
Section titled “Who invoked the action”An action invoked from Factorial knows who invoked it. The platform hands the process a
factorial object beside its parameters:
{ "company_id": "1234", "access_id": "5678", "entry_point": { "type": "ui_trigger", "location_id": "calendar.header.admin" }}company_id: the Factorial company the action runs for — the company that installed your App.access_id: the Factorial access (the user, within that company) who triggered it.entry_point.type: which entry point started it:ui_trigger,form,agent_toolorbackend.entry_point.location_id: for a button, the location it was rendered at. Absent when the entry point has none.
const factorial = fcode.context.factorial;if (!factorial) { throw new Error("This action only runs when invoked from Factorial");}const { company_id: companyId, entry_point: entryPoint } = factorial;fcode.log(`Running for company ${companyId} from ${entryPoint.type}`);factorial = fcode.context.factorialif factorial is None: raise Exception("This action only runs when invoked from Factorial")company_id = factorial.company_id # or factorial["company_id"]logger.info(f"Running for company {company_id} from {factorial.entry_point.type}")Trust fcode.context.factorial, not the parameters. Parameters are what the caller submitted —
often a user filling a form — so a parameter named company_id proves nothing. The factorial
object is set only by the platform, from the request Factorial itself made on the trusted path, and
never appears inside fcode.context.parameters; read the company and the access from here.
Its absence is the answer to “was I called by Factorial?”. An execution that did not come from
Factorial — a webhook, a schedule, a run
from the console, the API or the CLI — has no factorial object:
fcode.context.factorial is undefined in JavaScript and None in Python, never a placeholder.
Neither is it carried over to other executions: a rerun from the console, an execution the process
starts with fcode.processes.run, or a team error handler has
none of its own.