How to use
- Copy the installation command and review the source package before installing it in your own agent environment.
- Check the source document for required tools, credentials and supported hosts.
- Describe your input and desired deliverable; review the output against the source material.
npx skills add https://github.com/factorialco/factorial-code-skills --skill fcode-formsBefore you start
- Installation does not include a model subscription, API allowance or permission to access your files.
- Repository stars and installation counts describe activity, not an independent quality or security test.
Rendered from the source repository. Headings, examples, code, tables, links, and referenced images are preserved.
Factorial Code — Forms
A Factorial Code Form embeds a process's input-parameter form on any webpage. Each submission starts a process execution with the form data, and the result is handled in-page (messages, redirects, callbacks). For the schema itself, see fcode-json-schema.
Gotchas
- The form is the process's `parametersSchema.json`* — there is no separate
form definition. To change fields/validation/labels, edit the schema, not the embed code.
- `team` and `process`/`processId` are both mandatory on every embed, and
both take slugs — not the per-workspace UUIDs.
- Always pin the embed to the `stable` alias
(data-fcode-form-process-version="stable" / processVersion: "stable"). An unpinned form runs the current version, so every fcode push changes it immediately. Details below.
- An unknown version or alias doesn't fail the form — it silently runs the
current version (see "Pin the form to a version").
- The `Forms` flag must be enabled — in the Trigger from Factorial
section of the process Dashboard, or via factorial.form.enabled (legacy: "form": { "enabled": true }) in the process's metadata.json + fcode push — or the embed won't render.
- A form requires a Factorial identity unless you release it. By default a
form is reached through Factorial, which authenticates the person and injects the trusted context, and an anonymous visitor with the team and process slugs cannot read the schema or submit it. factorial.form.public opts out of that for the forms that genuinely need it. Either way the process behind the form is the guard for what the caller may do (below).
- A form opened from inside Factorial is configured in `factorial.form`, the
Factorial Action block (fcode-factorial-actions). form.enabled still works and is kept mirrored. A form offered to users on the App's own page is factorial.form.appTool; the install / settings / uninstall forms are named by form.appRole, or by the reserved slugs when a process declares no role (fcode-factorial-actions). factorial.form.public releases the form to anyone holding its link, for a form meant for people outside Factorial.
- A schema can't carry executable JavaScript.
embedFormOptions.onChange
and field transformFn were removed, and messages are markdown — raw HTML is never rendered. Client-side behaviour lives in the embedding page.
- Never put secrets in embed code or `options` — they run in the browser.
- Connecting a third-party account (Slack, GitHub, …) is a schema feature —
"ui:widget": "oauth" plus an fcode.oauth flow whose completion redirects to the SDK's callback page. Never ask users to paste an API token into a form when the vendor offers OAuth. See "Connect an external account".
- Form text is translated with `fcode.i18n("key")` tokens in the schema,
substituted server-side before the schema is served. See fcode-i18n.
- Form submissions run under a request timeout (about a minute) — keep the
synchronous process fast, or run long work asynchronously (see below).
- Prefer driving UX from the process return value (below); reserve
onSuccess/onError for client-only logic.
Enable a form
- Create the process and define its input parameters (these become the fields).
- Enable the
Formsflag — either in the Trigger from Factorial section of
the process Dashboard, or from the CLI workspace in processes/<slug>/metadata.json, then fcode push:
{
"name": "Contact request",
"tags": [],
"factorial": { "enabled": true, "form": { "enabled": true } }
}The form is one entry point of a Factorial Action — the same factorial block also holds the button, the Factorial One tool and the backend job (fcode-factorial-actions, field reference in fcode-cli). The legacy top-level "form": { "enabled": true } still works and is kept mirrored with factorial.form.enabled; prefer factorial in new work.
The lifecycle forms of a marketplace app are the processes with the reserved slugs install, settings and uninstall (sync is the fourth reserved slug, for the integrations sync process); the platform derives their role from the slug.
A form the App offers its own users, on the App's page in Factorial, is "factorial": { "form": { "enabled": true, "appTool": true } }. Never write form.appRole: USER_FACING_FORM in a new process — that is exactly what appTool sets, and the platform keeps the two mirrored. The other three values are a different matter: the block has no switch for them, so an install, settings or uninstall form is named either by form.appRole or by carrying the reserved slug. Prefer the slug in a new process; fcode-factorial-actions has the rule and what it takes to adopt one in an App that already ships.
An install or settings form is re-opened after the app is configured, so it should show the current values rather than an empty form — the preRenderProcess pattern for that is in references/advanced.md.
Read submitted values in process code like any parameters: const { context: { parameters } } = fcode;
What a form may trust
A form is reached through Factorial, which authenticates the person opening it: the install / settings / uninstall screens, the forms an App offers on its own page and the UI-trigger dialog (fcode-ui-triggers) all submit through Factorial's backend, and requiredPolicies is enforced before anything runs (fcode-factorial-actions). An anonymous visitor holding the team and process slugs reaches nothing.
Unless the form sets factorial.form.public. That flag releases it to anyone with the link, and then nothing has authenticated the caller at all: the company_id and access_id such a submission carries are whatever the page sent. Turn it on only for a form meant for people outside Factorial, and treat everything below as the only protection it has.
That settles who is calling. It settles nothing about what they submitted, so the process behind the form is still the guard:
- Treat every submitted parameter as caller-controlled — including the ones
an embedding page pre-filled (company_id, triggered_from_location, access_id). Never authorize on them.
- Return nothing the caller shouldn't already have — the result of a
submission, and a preRenderProcess's variables, reach whoever opened the URL.
- Keep credentials one-way: a settings form reports only whether a secret
is set, never its value (references/advanced.md).
Embed a form
Two mandatory inputs, both slugs, plus the version pin you should always add:
- `fcode-team-slug` — from
https://code.factorialhr.com/platform/<fcode-team-slug> - `fcode-process-slug` — the Slug field on the process Dashboard (e.g.
send-welcome-email)
- process version — pin it to the
stablealias (next section)
Use the slug, not the process ID. IDs are per-workspace UUIDs, so an id-based embed breaks when the snippet moves between workspaces; slugs survive (existing id-based embeds keep working, and the React prop is still named processId — feed it a slug). Slugs are editable with no redirect for the old value, so settle them before handing out embed code — renaming one breaks every embed already pasted into a page.
Load the SDK once (needed for the data-attribute and Fcode.initForm methods):
<script defer src="https://code.factorialhr.com/sdk/forms.js"></script>Method 1 — data attributes (SDK replaces the element):
<div
data-fcode-form-team="<fcode-team-slug>"
data-fcode-form-process="<fcode-process-slug>"
data-fcode-form-process-version="stable"
></div>Method 2 — `Fcode.initForm` (selector or DOM element):
<div id="my-fcode-form"></div>
<script>
Fcode.initForm("#my-fcode-form", {
team: "<fcode-team-slug>",
process: "<fcode-process-slug>",
processVersion: "stable",
});
</script>Method 3 — `FcodeForm` React component (React 17/18; install @factorialco/fcode-react-forms):
import FcodeForm from "@factorialco/fcode-react-forms";
const MyComponent = () => (
<FcodeForm
team={"<fcode-team-slug>"}
processId={"<fcode-process-slug>"}
processVersion={"stable"}
/>
);In SSR frameworks (e.g. Next.js), import it dynamically with ssr: false.
Pin the form to a version
The version pin (data-fcode-form-process-version / processVersion) takes a published version tag (v1.0.0) or an alias — use stable, so releases and rollbacks happen by moving the alias, never by editing the embedded page (alias model in fcode-core-concepts; release commands in fcode-cli). The pin applies to both requests the form makes — loading the definition and submitting it — and the process Dashboard writes it for you: pick a version or alias in the selector next to the embed code and copy the generated snippet.
An unknown version or alias silently runs the current version — the platform only logs a server-side warning, same as webhooks (see fcode-cli). A typo in the attribute is invisible: check the execution's version when a submission behaves unexpectedly. Direct calls to the form endpoints also accept the version_tag query parameter documented in fcode-cli.
Handle submission results
Default: a loading overlay shows during execution; on success the form is replaced with a success message, on error an error message.
Callbacks (same shape across methods):
Fcode.initForm("#my-fcode-form", {
team: "<fcode-team-slug>",
process: "<fcode-process-slug>",
onSuccess: (formId, processExecutionResult, formSubmittedData) => {},
onError: (formId, error, formSubmittedData) => {},
});With data attributes, point to global functions via data-fcode-form-on-success="HANDLER_NAME", data-fcode-form-on-next-step="..." and data-fcode-form-on-error="...".
Drive behavior from the process return value (no client code needed):
return { message: "Thanks, **we received your request**." }; // success message (markdown)
return { message: "| Item | Status |\n|---|---|\n| Sync | Done |" }; // GFM tables work too
return { status: 400, body: { formErrors: { // inline validation errors
fields: { email: "Invalid email." }, global: ["A global error."] } } };
return { status: 400, body: { errorMessage: "**Sync failed** — retry." } }; // error message (markdown)
return { redirect: { url: "https://example.com", timeout: 2000 } }; // redirect after submitMessages are markdown, not HTML. message, errorMessage and a schema's markdown.before/markdown.after blocks render as GitHub Flavored Markdown — tables, headings, links, images, code blocks, task lists. Raw HTML is dropped, never rendered (so nothing authored can execute); put behaviour in the success / next-step / error callbacks instead.
Keep it fast, or go async
The submission waits for the process to finish, under a request timeout (about a minute). Heavy work done inline — slow API calls, large exports, multi-record syncs — will blow the timeout and fail the submit.
Go async when the work can be slow:
- Embed `async: true` — the submission returns
201+ an execution ID
immediately instead of waiting for the result (see references/advanced.md).
- Hand off to another process — kick off the heavy work with
fcode.processes.run("process-identifier", options) (see fcode-javascript / fcode-python) and return a quick acknowledgement (message/redirect) rather than awaiting it inline.
Stay synchronous only when the request is genuinely fast, or when data must flow between steps. For passing data, don't block the submit — instead:
- `preRenderProcess` computes server-side
variablesbefore the form
renders (see references/advanced.md).
- Chained multi-step forms carry state forward via
nextProcessId+
variables (below). If the steps are only visual — no work between them — use ui:steps instead and keep a single process.
Multi-step forms
Two mechanisms, and picking the right one matters:
| Mechanism | Processes | Executions | Use when |
|---|---|---|---|
| `ui:steps` — steps within one form | one | one, after the last step | The split is purely visual; all the logic lives in one process |
| `nextProcessId` — chaining | one per step | one per step | An intermediate step must actually execute something (validate externally, compute variables for the next form) |
They compose: a chained process's form can itself declare ui:steps.
When a form grows long — roughly ten fields or more — split it with `ui:steps`, grouping related fields per step with the required ones early.
Steps within a single form (ui:steps)
Declare steps in the schema's root ui node, assigning each property to a step. The SDK shows one step at a time with a "Next" button; only the last step's button submits and starts the (single) execution, which receives every collected parameter exactly as a one-page form would — process code needs no changes.
"ui": {
"ui:steps": {
"config": { "layout": "tabs" },
"steps": [
{ "title": "About you", "description": "Optional text", "fields": ["name"] },
{ "title": "Details", "fields": ["age", "subscribe"] }
]
}
}config(optional):layout—"tabs"(default) or"sidebar";
nextLabel / backLabel — button labels, default "Next" / "Back". ui:submitButtonOptions applies to the last step's button.
- "Next" validates only the fields on screen (including that step's share of
required); a Back button and the step navigation revisit completed steps without losing input, and unreached steps stay disabled.
- Properties not listed in any step are appended to the last one; an unusable
ui:steps falls back to the ordinary one-page form with a console warning.
- Supports root-level
properties+required+dependencies; root-level
oneOf/allOf compositions are not split into steps. SDK version requirements in references/advanced.md.
Chaining processes (nextProcessId)
Each step is its own process. Return the next process's slug to advance:
return { nextProcessId: "collect-shipping-address" };The field name is still nextProcessId and it accepts a slug or an id — use the slug, so the same chain works in every workspace.
The SDK then renders the form for nextProcessId. Each later step receives all previous steps' data and results in fcode.context.parameters under a steps array. Return a variables node alongside nextProcessId to pass state forward.
Connect an external account (OAuth)
A form can make the user authorize a third-party provider before it is submitted. Declare a string or boolean property with "ui:widget": "oauth" (an object property takes "ui:field": "oauth" instead — rjsf never reads ui:widget on objects):
"github_account": {
"title": "GitHub account",
"description": "You will be asked to authorize in a new window.",
"type": "string",
"ui": {
"ui:widget": "oauth",
"ui:options": {
"authorizationUrl": { "$ref": "#/variables/authorizeUrl" },
"connectLabel": "Connect GitHub",
"connectedLabel": { "$ref": "#/variables/connectedLabel" },
"onComplete": "reload"
}
}
}The SDK renders a Connect button that opens authorizationUrl in a small popup, waits for the flow to end, closes the popup and fills the field. The property's title / description are the message around the button (markdown, as everywhere; markdown.before/after for longer copy).
ui:options | Default | Purpose |
|---|---|---|
authorizationUrl | — | The platform authorization URL returned by fcode.oauth.start(). Required; an invalid URL disables the button with a console warning |
connectLabel / connectedLabel / pendingLabel | Connect / Connected / Waiting for authorization… | Button text before, after and during the flow |
onComplete | none | What the form does once connected (below) |
popup | { "width": 600, "height": 700 } | Popup size, centred on the page |
closeDelay | 3000 | Milliseconds the popup stays open after finishing, so the user sees the confirmation |
The three rules that make it work:
- `authorizationUrl` comes from `fcode.oauth.start()`, called in a
preRenderProcess, and is injected with {"$ref": "#/variables/…"} — a plain {{mustache}} token is HTML-escaped (/ → /, & → &) and the SDK refuses the URL. Pre-render contract in references/advanced.md.
const flow = await fcode.oauth.start({
authorizeUrl: "https://github.com/login/oauth/authorize",
clientId: fcode.env.GITHUB_CLIENT_ID,
scope: ["public_repo"],
onComplete: "github-oauth-callback",
data: { companyId },
});
return { variables: { authorizeUrl: flow.authorizationUrl } };Never build the authorization URL, the state or a PKCE pair by hand. The platform holds all three, plus the one redirect_uri registered with the provider — which is the only way this can work at all, since providers require a pre-registered URI and every install is its own deploy- workspace.
- The completion process needs no webhook. The platform invokes
onComplete itself once it has verified and spent the state, so there is no endpoint to expose and nothing to authenticate. It arrives with code, codeVerifier, redirectUri (replay it in the token exchange — providers compare it byte for byte), data and state, or with error / errorDescription when the provider refused. It exchanges the code, stores the tokens server-side (a sensitive variable, or the datastore with the encrypted flag — see fcode-javascript / fcode-python) and ends by redirecting the popup to the SDK's callback page:
const { state } = fcode.context.parameters;
return {
status: 302,
headers: {
Location: "https://code.factorialhr.com/sdk/oauth-callback.html"
+ "?status=success&value=" + encodeURIComponent(login)
+ "&state=" + encodeURIComponent(state),
},
};That page tells the form how it went — the form only trusts a message from the very window it opened — then closes itself. Query parameters: status (success; anything else counts as an error), value (becomes the field value), state (required, echoed from the callback parameters), message (shown under the button on error), plus any extra parameter an object field should receive.
Echo state on every exit, the error ones included. It is what binds the outcome to the popup the form opened, and a completion that omits it is discarded when it arrives over the fallback channel — the COOP-severed case onComplete: "reload" exists to recover. The flow then ends as cancelled even though the token exchange succeeded and the token was stored.
- `value` is an opaque handle (an account login, a connection id) — never
a token: it reaches the browser and travels in the submission. The connected state is a signal for the user, not a proof: the process receiving the submission must verify the connection on its side.
Field value. boolean → true; string → the value parameter; object → every parameter except status / message. Empty until connected, so marking the field required keeps the form from being submitted before the account is connected. Closing the popup early counts as cancelled and re-enables the button. An error shows its message under the button and reloads the form definition, whatever `onComplete` says: the callback has run and may have consumed the one-time state in the authorization URL, so only a fresh preRenderProcess run can put a working URL behind the button. Typed values and the message survive the reload, nothing is submitted, and the user simply retries. A reload that fails leaves the form as it was.
A `default` renders the button connected. The button is in its connected state whenever the field has a value, so a preRenderProcess that finds an existing connection returns the property with a default set to the connection handle — the button renders as connected (disabled) and the handle travels with the submission like any other default. This is how a returning user, a connection finished after the popup was closed, or an onComplete: "reload" lands on the connected view; a value arriving this way also clears an earlier error message.
`onComplete` — what happens once connected:
none— the button shows its connected state; the user submits as usual and
the process receives the value with the other parameters.
submit— the form is submitted immediately, so the return value's
message / redirect / nextProcessId apply right away. In a ui:steps form this advances to the next step.
reload— the form fetches its definition again, re-running the
preRenderProcess, so the server can check the connection and render the connected state (different copy, more fields, a default that turns the button into "Connected as …"). Values typed into other fields are kept. Because the server is the source of truth here, the form also reloads when the popup is closed without reaching the callback page — the only mode that reloads on cancel, since the callback never ran and the URL is still good.
Limits worth knowing: the popup is opened from the click itself, so default popup blockers let it through — a browser set to block all pop-ups gets a message under the button instead; a Cross-Origin-Opener-Policy header on the embedding page or the provider can sever the link with the popup, in which case the flow ends as cancelled and onComplete: "reload" is the recovery path. Needs the hosted SDK or @factorialco/fcode-react-forms ≥ 3.3.0 (f0 control: @factorialco/rjsf-f0 ≥ 2.3.0).
A complete sample — pre-render starting the flow, the completion process, the form process verifying the connection — is in fcode-examples (references/oauth-connect.md).
Automatic file uploads
A file field is "type": "string" with a "ui": { "ui:widget": "file" } key inside the property:
{
"properties": {
"inputFile": { "type": "string", "ui": { "ui:widget": "file" } }
}
}(A root-level ui map keyed by field name — rjsf's uiSchema convention — is also merged, but per-property ui is the documented form; the root ui is mainly for form-level options like ui:submitButtonOptions. For secret inputs prefer "isSensitive": true on the property — it renders a password widget automatically; see fcode-json-schema.)
On submit the file is uploaded to Storage before the process starts, and the parameter arrives as an fcode.storage://… reference (an array if multiple files allowed). Strip the prefix to download:
const { context: { parameters } } = fcode;
const stream = await fcode.storage.download(
parameters.inputFile.replace("fcode.storage://", "")
);Uploaded files count toward storage limits (1,000 files, 50 MB each — fcode-core-concepts › Plans & limits) — delete them at the end of the process if only needed transiently.
Advanced
For styling and the two themes (including the f0 theme to use when embedding in a React app inside Factorial), markdown message rendering with f0 components (markdownComponents), initial/hidden values, async submission, custom headers, API-host override, variables replacement, pre-rendering current values into install/settings forms, reacting to user input from your own page (the React onChange prop, the fcode-forms-* DOM events), and modal rendering, read references/advanced.md.



