Automation Robots
Required Access Level
Admin or Super Admin
What is a robot?
A robot is an automated program that executes tasks without human intervention. Robots can:
- Start processes automatically at scheduled times
- Fetch data from external systems to feed Flowi Agentic
- Execute steps that do not require human interaction
- Integrate with ERPs, tax systems, and other platforms
Each robot is written in Java or in Python — its runtime, chosen when it is registered. A Java robot is a compiled JAR file; a Python robot is a .zip package with the code and a manifest. Both run the same way: on a runner, on demand or on a CRON schedule, with the same parameters, secrets, state, log and limits.
Attention: Robots ≠ Agents
"Robots" handle rigid systemic integrations (code). If you need cognitive automation (reading PDFs, interpreting free-form text, or making flexible AI-based decisions), refer to the AI Agents manual.
Listing Robots
Access Integrations & automation → Robots in the sidebar.
The list answers the question that matters at a glance — did anything break? — with one row per robot:
| Column | What it shows |
|---|---|
| Name | the robot's name and key, its runtime (Java or Python) and a Global tag when the robot belongs to the platform |
| Status | an active/inactive switch — see Activating and Deactivating |
| Schedule | the cron expression and the next run, or on demand when there is none |
| Last execution | the result and when it happened |
| Artifact | the active version of the JAR or package, or a warning that one is missing |
A robot that has never run reads Never ran rather than showing nothing: no result and a good result should not look alike.
Clicking the row opens the robot's page.
Registering a new Robot
- Click on New Robot
- Fill in:
- Key — the robot's identifier, lowercase letters, digits and hyphens only (e.g.
sync-records).runtimesandsdkare reserved, because they are already addresses of the robots screen; a robot registered with the keyruntimesbefore this release no longer opens from its detail page (the list and the other routes keep working) and must be recreated under another key - Name — robot identification
- Runtime — Java or Python. It is fixed once the first artifact is uploaded: to switch languages, create another robot. When the installation does not run Python yet, the option is disabled and the screen says which setting the platform administrator must turn on
- Main class (Java) — the JAR class that implements
FlowIaRobot, e.g.com.example.MyRobot; or Entry point (Python) —module:function, e.g.invoice_sync.main:run, equal to the manifest'sentryPoint - Schedule (cron, optional) — a six-field cron expression (e.g.
0 0 8 * * MON= every Monday at 8 AM) - Parameters — optional JSON configuration (the column is
jsonb: a malformed object is refused on save, not on the first scheduled run) - What the robot can access — the capabilities, each named after what it unlocks
- Key — the robot's identifier, lowercase letters, digits and hyphens only (e.g.
- Click Create
The screen opens the new robot's page with a next step: upload the JAR notice (or the package, for a Python robot). The file is not part of the registration form because it is versioned per robot — it is sent with Upload JAR (.jar) or Upload package (.zip) on the robot's page, and only then does the robot have something to run.
The file has a 200 MB limit by default (ROBOTS_MAX_ARTIFACT_SIZE_MB); it applies only to the robot artifact upload, and the rest of the platform stays at 10 MB (UPLOAD_MAX_FILE_SIZE). The file is read as a stream, never held whole in memory, and anything larger is refused with 413, with the size and the limit in the message — the screen states the installation's ceiling. With too many uploads in flight on the installation (ROBOTS_MAX_CONCURRENT_UPLOADS, 4 by default) the answer is 429 and the screen asks you to try again in a minute. The proxy in front of the platform must accept the same size. The platform stores the JAR under a sanitised name (only letters, digits, ., _ and -), so the file name on your computer can be anything.
Python package
A Python robot takes a package: a .zip with flowia-robot.json at its root, the code and, if there are dependencies, the wheels (.whl) in wheels/ — see Developing the Python package. The robot's entry point must equal the manifest's entryPoint. A JAR sent to a Python robot, or a package sent to a Java robot, is refused by its content, with a message saying what the file looks like. The platform never runs the package to check it: it reads the ZIP directory and refuses, naming the entry and the fix, names that leave the folder (.., absolute paths, backslashes), symlinks, archives outside wheels/, any file in wheels/ that is not a .whl (a .tar.gz would run code on install), a missing or invalid manifest, an entry point that is not in the package, and packages that look like a decompression bomb. This installation only accepts Python packages with ROBOTS_PYTHON_ENABLED=true, which should be turned on only when the runners have Python.
Cron Expression — six fields
Format: second minute hour day-of-month month day-of-week — the format the platform's scheduler reads, with the second first.
Examples:
0 0 8 * * *— every day at 8 AM0 0 8 * * MON— every Monday at 8 AM0 */30 * * * *— every 30 minutes
An invalid expression is refused on save, with a message saying what is wrong. A five-field one (0 8 * * MON, the Linux crontab format) is refused too, and the message already suggests the version with the second in front. Once the cron is accepted and the robot is active, the list and the robot's page show when it runs next.
A robot registered before this rule may have stored a cron the scheduler no longer reads. While it is active, the list and its page show This schedule no longer runs instead of the next run: open Edit robot and save the schedule in six fields.
A tick only fires if the previous one has finished
A robot on a one-minute cron that takes five minutes does not run five copies at once, and does not queue them either: the tick is skipped while the previous execution of that robot, in that tenant, is still running. The reason is logged by the application.
The manual trigger is not the exception: Run now while a run is in flight is refused, with a message saying so — see Manual Execution. The only difference between the two is what you see: the tick disappears into the log, the click gets the refusal on screen.
The check is per tenant: one slow subscriber of a global robot does not hold back the others.
The JAR is checked on upload
When the JAR is uploaded, the platform scans it and verifies that the registered main class exists inside it and implements FlowIaRobot. If it does not, the upload is refused straight away and the message lists the robot classes the file actually contains — usually it is a matter of copying the right name into the field.
The scan reads bytecode metadata and does not load the classes into the server process. Loading tenant-supplied code would run its static initialiser inside the platform, which is exactly what the sandbox exists to prevent.
Without this check, a mistyped main class would only surface when the robot ran — for a scheduled robot, hours later, as an execution failure rather than a registration error.
On the same upload the platform computes the file's fingerprint (SHA-256) and stores it alongside the version. Whenever the JAR has to be recovered — after a deploy, for instance, when the file is no longer on the server's disk — the platform checks that fingerprint before running it. If it does not match, the execution is refused with a message saying the file is not intact, instead of failing further along as "main class not found in JAR", which would point at the wrong thing.
Each execution is pinned to the JAR version that was active when it was queued. If you upload a new version while an execution waits for a runner, it runs the old version; the new one applies from the next execution on. Executions created before this rule run whichever version is active at the time.
Parameters declared by the JAR
A robot can declare the configuration it needs — a Java robot on the class itself, a Python robot in the parameters of flowia-robot.json (same fields: name, label, required, secret):
@RobotParameter(name = "erpUrl", label = "ERP URL", required = true)
@RobotParameter(name = "erpToken", label = "Token", required = true, secret = true)
public class SyncCustomerRecords implements FlowIaRobot { … }The platform reads the declaration when the JAR is uploaded and builds the form from it, instead of asking for free-form JSON where a mistyped key only surfaces when the robot runs.
Filling in and saving that form — and even opening it to see what is stored — takes a tenant Admin or a Super Admin, the same role the rest of the robot registration requires. A manager or an ordinary user is refused: the configuration names every declared parameter and shows where a secret parameter's reference points, which is already half the way to the secret.
A secret parameter points at where the secret lives
A field marked secret = true does not accept the value. Write a reference into it, and the secret goes on living in the tenant variable that was built to hold it. A literal value — the password typed in there — is refused on save, both in the configuration and when editing the robot, and the screen already warns on the field itself, in red, before you click; it is the same rule as the mail inbox password. A literal value saved before this rule never comes back to the screen: it shows as ********, with a note that it is still stored and hidden. Saving without touching it keeps what is stored; replace it with a reference when you can:
| Reference | Where the value comes from |
|---|---|
${var.erpToken} | a variable from Variables and secrets — and, when there is none, a variable registered inside a process —, including the ones marked secret |
${config.erp.baseUrl} | the tenant's configuration, by dotted path |
The reference is resolved at the moment the runner asks for the parameters, inside the tenant's context. What is stored on the robot's registration is ${var.erpToken} — harmless in a backup, on a screen and in an audit log. Parameters no longer travel on the command line of the isolated process either: the sandbox fetches them over the same per-execution authenticated channel that already carries the logs.
When the parameters are saved, the platform already checks every ${var.…}: a variable that does not exist refuses the save naming the missing one. While the referenced variable does not exist, the Create variable button under the secret field opens a creation dialog with the key filled in, without leaving the robot page or losing the other parameters you were editing; once the variable exists, the button goes away.
A reference that does not resolve fails the execution and names the reference. It is never replaced by an empty string: a blank token would make the robot authenticate and report whatever the far end said about it, instead of failing where the problem is.
What is still not protected
Once resolved, the value is in the robot's memory — and the robot is code the tenant itself uploaded. It can log the value, send it anywhere or write it to a file, and no design prevents that short of not giving the robot the secret.
What this design does buy is narrower: the secret is not on the command line, not in the robot's stored configuration, not in the response of GET /a/robots, and not written by the platform into the execution log. Still use least-privilege, rotatable credentials, never the master password of a system — and, when writing a robot, never log the value of a parameter that could be a token.
The execution token left the command line as well: the agent hands it to the sandbox over standard input. That holds from the updated agent onwards — an older agent still passes the token as an argument, and when it does the execution log carries a warning saying exactly that. If you see that warning, update the runner-agent on that machine.
What the robot can reach is no longer everything either. Each robot declares its capabilities — reading its own parameters, writing its own log and reading and writing its own state always apply, which is why they show ticked and locked; reading or writing the CMS, starting a process, counting instances, listing members and uploading a file are ticked one by one. Each box carries the name of what it unlocks and a one-line explanation — the technical code (cms:write and so on) is not shown. The execution token carries only what was ticked, and the route refuses the rest with a 403, naming the capability that is missing and where to tick it.
A robot that already existed was given every capability on upgrade, so no automation stops working unannounced. A new robot is born with the minimum and you tick what it needs — on the creation form, and afterwards on the robot's own screen under Save access. If an execution fails saying a capability is missing, that is where it is fixed, and the message says which one. Save access stores only the capabilities: it does not re-send the parameters or the active state. If the server refuses — an old cron that is no longer accepted, for instance — the message shows right below the button.
A global robot is not editable by the tenant: the platform maintains it, and its capabilities come with it.
Either way, treat the runner machine as a trusted machine: whoever has an account on it can read the process's memory, and no design prevents that.
Global robots and each tenant's configuration
A platform robot is visible to every tenant, but there is one JAR and there cannot be one configuration. Hence the split: the JAR declares the shape, each tenant supplies the values.
On a global robot's page a tenant administrator fills in the declared fields and saves. Those values apply only to that tenant.
The same form serves a tenant-owned robot — the difference is only where the value is stored: on the robot, because it has a single owner. Nobody using the screen has to know the difference.
Changing the robot itself — name, schedule, main class, uploading a new JAR, deleting — belongs to the platform administrator. For a tenant administrator those commands are not shown on a global robot's page: in their place is a notice saying whose change it is and pointing at the subscription form just below. They used to be shown and answer with a refusal; offering a button that can only refuse is worse than not offering it.
What stays available is Run now, and not by oversight: a global robot only runs through the tenant's subscription, so running it is precisely what the tenant administrator can do with it. For the platform administrator nothing changes — every command is there.
In the list and at the top of the page, the Global tag identifies a platform robot, the same way global processes are already marked.
A global robot only runs inside a tenant
A global robot is the JAR's registry entry, not something that runs by itself. There is no "global" execution: every run happens inside a tenant that subscribed to the robot, with that tenant's parameters.
So a tenant that never filled in the configuration does not run the robot — not from the button, not on a schedule. The refusal names the tenant and where to configure it. A tenant that switched its own subscription off does not run either, however hard someone presses the button.
The default values shown on the global robot are a template offered to whoever subscribes, never what executes. Were they used at execution time, every subscriber would run with the platform's configuration — and, for a secret field, with its credential.
When a new version asks for more than the previous one
If the platform administrator uploads a JAR declaring a new required parameter, every subscription without that value becomes incomplete. The upload succeeds — a new version has to be publishable — but the robot does not run in that tenant until someone fills it in, neither on schedule nor by button.
That is deliberate, and it is the choice between two bad things: a robot stopped with the reason written down, or a robot running on half a configuration and reporting success. The second is worse, because nobody goes looking.
Scheduling a global robot
The schedule belongs to the robot, and each tenant can switch its own off. At the scheduled time the platform fires one execution per subscribing tenant — ten subscribers, ten executions, each in its own tenant's context and with its own parameters. One failing does not interrupt the others, and an incomplete subscription is skipped with the reason in the log.
Activating and Deactivating
Use the active/inactive switch in the list's Status column to control whether the schedule is running. A deactivated robot is not fired by its schedule, but remains registered and can still be run by hand. On a global robot only the platform administrator switches it; saving the tenant subscription's parameters does not change whether that subscription is on or off.
Editing a robot
On the robot's page, Edit robot opens a dialog with the name, the description, the schedule, the timeout (in seconds), where it runs (any runner or dedicated — see A robot can refuse the shared runner), the active switch, the main class (or entry point) and the runtime. The runtime only changes while the robot has no artifact: after the first upload the field is locked, with the reason — a Python package does not run as Java, and the server refuses the change with 400. The key does not change there. A refusal from the server — an invalid cron, for instance — shows up inside the dialog, without discarding what you typed. A global robot does not offer the button to a tenant admin.
Through the API, a PUT /api/a/robots/{key} that does not send enabled, parameters or timeoutSeconds keeps the stored value of each — an integration that only renames the robot does not switch it off or erase its parameters. To clear the parameters, send parameters as an empty string.
Free-form parameters and extra parameters
A robot has two ways of receiving configuration, and both reach the robot the same way, through context.getParameters():
- Fields declared by the JAR — the form built from
@RobotParameter(see Parameters declared by the JAR). The JAR defines the key, with required and secret; on a global robot, each tenant fills in its own. - Free-form parameters — keys the administrator writes, with no declaration. It is the JSON typed at registration, and it can be fixed later: on the robot's page, the Parameters card is a key/value editor, with Add parameter, a bin on each row and Save parameters.
When the active JAR declares fields, the card is called Extra parameters and shows only the keys the JAR does not declare — the declared ones stay in the form above. A key the JAR declares cannot be added as an extra: the row turns red and Save parameters is disabled, because two editors for the same key would overwrite each other. Each editor writes only its own keys: saving the declared form does not erase the extras, and saving the extras does not rewrite the declared fields — not even right after the form above was saved, nor from another tab.
On the extras, a reference to a variable (${var.…}) is recommended for a secret, but it is required only on the fields the JAR declares secret. A key that looks like a secret (token, password, senha, secret, key…) with a value written out gets a yellow warning, without blocking the save — the value stays in clear on this screen. A key that some previous JAR of the robot declared secret stays masked as ******** even after a new JAR stops declaring it. Renaming the key of a masked row clears its value, which has to be typed again — the mask is never stored as a value, and the server refuses ******** under a key that has no stored secret.
A global robot does not show the card: a global robot's own parameters never reach an execution — each tenant runs with the values of its subscription.
The robot's page
Opening a robot shows its main class, schedule, parameters and active JAR version, and that is where the JAR upload happens — after the robot exists, because the file is versioned per robot.
Below it is the executions table: status, trigger (Manual or Scheduled), start, duration, the runner it ran on, and the error when there was one. A pending execution shows, in place of the error, why it has not run yet — no eligible runner available, a dedicated robot with no tenant runner, or an assignment that expired without being collected — and the open log repeats the reason and the runner. Hovering over the status and the trigger explains each one in a line. While any execution is open — PENDING or RUNNING — the table refreshes itself every few seconds, and stops asking once they all finish. There is no need to reload the page after clicking Run now. Clicking an execution opens its log, and the dialog carries Download .log: a text file with the whole log in the order it happened, each line marked with where it came from. Good for grep, for attaching to a ticket, and for reading away from the screen.
The complete file is sealed into object storage when the execution finishes — the runner uploads it, and the platform confirms it arrived before trimming the database, which then keeps only the last lines for the screen. An execution that has not been sealed downloads straight from the database: the button works either way. The log lines are fetched only at that point; the list does not carry them, so opening the page never pulls every line of every execution.
Manual Execution
Click Run now to trigger the robot outside of its schedule. Useful for testing. The button stays disabled while the robot has no active JAR — without one there is nothing to run.
When the trigger is accepted, the screen opens the log of the execution it just created — from the list, it takes you to the robot's page with that execution open. When it is refused, the server's message shows on screen, saying why: a run already in flight, a required parameter missing, the subscription switched off.
One run at a time, per robot and per company
If this robot already has a run in flight in this company, the manual trigger is refused with that message. Until 2026-09-02 it started anyway, which made sense while a run remembered nothing. With the State kept between executions (below), two simultaneous runs read the same position, process the same stretch, and whichever writes last wins — records go missing with no error at all. Wait for the current run to finish, or cancel it.
State kept between executions
A robot walking an incremental feed — the next page of an API, the last id already imported — has to remember where it stopped. That is the State: key/value pairs the robot writes during a run and reads on the next one.
State is per robot and per company. On a global robot each subscribing company has its own: ten subscribers do not share one pagination position, each resumes where it itself stopped.
It is not the same thing as the declared parameters, and the difference matters: a parameter is what a person decided and typed into the form; state is what the robot found out on its own. That is why they are two separate things on the screen — a robot does not write into the administrator's form, and an administrator editing the form does not overwrite the robot's cursor.
Each write by the robot lands immediately. If it records the page after processing each one and dies on page 7 of 20, the next run starts at 8 — not at 0.
On the robot's page, the State kept between executions card shows the current content as JSON. You can edit and save it (the day the source system renumbers its pages, this is where it gets fixed) or use Clear state to wipe it. Without that, a wrong value would only come out through database access.
Two bounds, stated on purpose:
- 64 KB for the whole state, keys and values added together — not a per-key cap. State is where the robot notes where it stopped, not where it keeps the result. The write that would take the set past the cap is refused, with the size and the limit in the message, and the previous value stands. Volume goes to the CMS or to storage; the pointer stays here.
- Not a place for secrets. State is not masked and it shows on this screen. Tokens and passwords go into a tenant variable, referenced by a secret parameter.
Deleting the robot deletes its state with it.
Cancelling an execution
Every execution that is still open — no finish time, whether PENDING or RUNNING — carries a Cancel run button on its own row in the table. A finished execution offers no button: there is nothing to cancel on a closed row, and reopening it would erase the outcome the runner reported.
The confirmation says which of the two cases yours is, because they are not the same thing:
| Situation | What confirming does |
|---|---|
PENDING — no runner has collected it yet | Settled immediately. The execution leaves the queue, becomes CANCELLED, and never runs |
RUNNING — a runner already collected it | The request is recorded now, the row is marked Cancel requested and stays RUNNING. The agent on that machine finds out at its next heartbeat, stops the robot, and reports CANCELLED |
Since the table already refreshes itself while any execution is open, the state change shows up without a reload. Who cancelled and when the request was made are both recorded.
Cancelled is not a failure. The execution ends with status CANCELLED, with no error message and no failure notification to whoever triggered it. Reading a cancellation as a failure sends someone to debug a robot that did exactly what it was told.
The limit: the platform does not reach inside the customer's machine
Cancelling a running execution is a request the agent has to come back for. If that agent never calls again — machine off, network cut, process killed — the robot may still be running on a machine the platform does not administer.
What the platform always does: it stops dispatching that execution and stops counting on it. The lease expires, the row is closed as CANCELLED with the explanation written on it, and the runner stops being treated as busy on its account. What it does not do is end a process on someone else's server — there is no path for that, because the platform never opens a connection into the customer's network; it is always the runner that calls out. Whoever administers that machine is who ends the process.
Monitoring Executions
The execution history for each robot shows:
- Start and end date/time
- Status:
SUCCESS,FAILEDorCANCELLED - Output log or error message
The history is not forever: an execution that finished more than 180 days ago leaves, with its log, in the nightly cleanup. The latest execution of each robot always stays, and an execution in progress never leaves. The window belongs to the installation — whoever runs it can raise it or turn the cleanup off (Data retention).
In case of failure
Robot failures are logged but do not interrupt other processes. Check the log to diagnose the issue and fix the JAR before reactivating.
When the runner's machine disappears mid-way
Two different things can happen, and the platform treats each one differently — because the risk is not the same.
The machine stopped responding before fetching the work. The robot never started. The execution goes back to the queue and the platform hands it to another eligible runner as soon as one is available. Nothing to do.
The machine stopped responding with the robot already running. Here the platform does not re-dispatch, deliberately: it cannot reach inside the process and has no way of knowing whether it is still alive on the customer's machine. Running the same robot again would make both executions read the same saved position in State, process the same stretch, and the last one to write would win — records disappear with no error at all. So the execution is closed as FAILED, with the reason written on it, and the robot is free for the next run. If the work really does need redoing, trigger the robot from the screen after checking what the interrupted execution managed to write.
The log has two sources, and the screen keeps them apart
[INFO],[WARN],[ERROR]— what the robot chose to record, by callingcontext.log(...).[process]— what the JVM printed:System.out,System.err, stack traces. It is shown indented and dimmed, because reading a stack trace as if the robot had written it on purpose leads to the wrong conclusion.
The two travel together, in batches during the run rather than only at the end, so they appear on screen in the exact order they happened: the context.log("about to download the file") sits right above the stack trace explaining why the download failed.
The level of a context.log(...) is one of TRACE, DEBUG, INFO, WARN and ERROR. Anything else is recorded as INFO.
Consecutive lines of the same source and level appear under a single [INFO], and line breaks are preserved: a context.log(...) carrying a stack trace stays readable line by line instead of collapsing into one paragraph.
A robot that prints too much keeps its head and tail, with a line in between saying how many were dropped. The head explains the configuration; the tail explains the failure. The ceiling covers both sources together, so a robot that logs heavily cannot push out the stack trace that explains its own failure.
What the robot prints reaches the screen verbatim
Nothing masks this output. A robot that prints a password puts that password in the execution log and in the database. Record what happened, never the credential used.
context.log(...) does not depend on the network
Log lines leave the robot on the child process's standard output, and the runner agent delivers them to the platform in the same batch as the process output. Two practical consequences:
- A robot killed at its timeout does not take its log with it. By then the agent holds the lines, not the process that was just killed.
context.log(...)does not fail because of the network, and does not retry. The guarantee it gives is "the agent shipped the batch", not "the platform acknowledged this line".
The other RobotContext calls talk to the platform and fail the execution when they cannot: writing to the CMS, starting a process, uploading an attachment. A robot that could not do what it was run to do does not finish as Success.
Runners — the machine a robot runs on
A runner is the machine that actually executes a robot. The platform schedules, assigns and shows the result; what opens the JAR and runs the code is always a runner.
Every robot runs on a runner. The platform schedules the work and shows the result; what opens the JAR and executes the code is always a machine registered as a runner. That is what keeps one client's code off the disk holding the other clients' configuration, credentials and attachments.
The practical consequence is direct: with no runner registered and answering, executions stay PENDING and the screen says why, instead of running somewhere nobody chose.
A Helm installation already has one. Since 2026-08-29 the chart brings up runner.replicaCount runners (1 by default) that generate their own key and enrol themselves as shared runners — runner.replicaCount is also the parallelism knob, because a shared runner runs one execution at a time. Outside Helm, registering at least one runner is part of bringing the installation up.
Why register a runner of your own
To reach a system inside the customer's network. A robot almost always exists to talk to an ERP, a fiscal system or an internal API. With no runner of your own, it is the platform that has to reach that network: an inbound firewall rule, a tunnel, or an exposed ERP. A runner installed inside the customer's network makes only outbound connections, and the question disappears instead of being answered.
To keep one customer's code off another customer's machine. On the shared runner, one tenant's JAR runs on the same machine as another's — exactly the exposure described in the What the sandbox does not isolate yet: the disk warning below. A runner of your own takes that customer's code out of there. If every tenant on your installation is a different customer, this is the strongest reason on the list.
A robot can refuse the shared runner
On the robot's page, the Runner field says where that robot accepts running:
| Value | What it means |
|---|---|
| Any available runner | the default — the installation's shared runner included |
| Dedicated — refuses the shared runner | only runners belonging to this tenant |
Marking a robot dedicated is a security decision, not a performance one: it is what keeps that customer's JAR off the same machine as another's. The price is that with no tenant runner available the execution stays pending instead of falling back to the shared one — and the executions screen says so, rather than quietly running it elsewhere.
Python robots: what the runner needs
A Python robot only goes to a runner that declared Python 3.10 or newer (and the version the robot's package requires). A runner without Python never receives a Python robot: the execution stays pending and the screen says that no available runner runs Python. The runner that ships with the chart and the runner-agent image already include Python 3.12 (the image uses a glibc base, because almost every binary PyPI library is manylinux). On a runner you installed on the customer's machine, install Python 3.10+ with venv and pip and, if it is not on the PATH as python3, point to it with --python.
Every execution starts from nothing, inside its own working directory: it creates a virtual environment, installs only the wheels the package carries in wheels/ (no network, no implicit dependencies, no install-time code execution) and runs the robot. Nothing survives from one execution to the next, and the time spent preparing the environment counts against the robot's timeout: a package with pandas takes 10 to 30 seconds just to install.
| Agent option | What it does |
|---|---|
--python <path> | The interpreter to use. Without it the agent looks for python3 (on Windows, python3 then python) |
--python-env NAME=VALUE | Lets one environment variable through to Python robots, for example PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright. Repeatable. The runner operator decides: a robot never sets variables. PATH, HOME, the temp variables, BASH_ENV, ENV and the PYTHON*, LD_*, DYLD_* and PIP_* families cannot be set |
--python-max-memory-mb <n> | Virtual memory ceiling for each Python robot (default 2048) |
The memory ceiling only exists on Linux. On Linux it is a real limit (RLIMIT_AS) and a robot that goes past it gets MemoryError. On macOS and Windows the system does not enforce it: the agent warns at startup and every execution logs a warning, and the robot runs with no memory limit. This is not equivalent to the -Xmx of Java robots. A robot that opens a browser (Chromium) reserves far more virtual memory than it uses: give it a larger value, such as 8192 or more.
Browsers are not in the image. Playwright and Selenium need binaries that are not wheels. Install them on the runner machine and pass the path through --python-env; the robot package cannot change that.
The same minimal environment as Java robots applies: the robot sees PATH, the variables the operator let through, and a HOME, a temp directory and a cache inside the execution's directory. It runs as the same user as the agent, like Java robots: the What the sandbox still does not isolate: the disk warning applies equally, and moving HOME only stops libraries from writing into the agent's folder by accident.
Registering a runner
Go to Integrations & automation → Runners in the sidebar — it is a sibling of the robot list, not part of the platform area: whoever manages a tenant's robots manages that tenant's runners too.
Download the agent with the Download the agent (flowia-runner-agent.jar) button at the top of the Runners screen, and copy the file to the machine. It needs Java 21 or newer installed.
On the machine, generate the key pair:
java -jar flowia-runner-agent.jar --initThe command prints the public key and its fingerprint. The private key never leaves that disk.
Click New Runner — or Open the New Runner form, right under step 2 — give it a name (e.g.
erp-fiscal-01), paste the public key and pick a scope.On the machine, start the agent:
java -jar flowia-runner-agent.jar --url https://<your-installation> --name erp-fiscal-01
The screen has a copy button on each of the two commands.
The agent checks, on startup, that it can reach the platform. Before claiming any execution it tries the RPC address the platform gave it. If it cannot reach it, it stops there and says which address it tried. It is nearly always platform configuration: app.api.base-url must be the platform's address as seen from outside it — localhost there is the platform itself, never the runner's machine.
The key is born on the machine, so nothing secret travels
The platform stores only the public key — which is public by definition. This screen never shows, generates or hands over a token: if you are looking for a secret to copy, the direction is the opposite of what you expect. A public key pasted in the wrong place leaks nothing.
Scope — what that machine accepts
| Scope | Accepts |
|---|---|
| Every robot of this tenant | any robot belonging to the tenant that owns the runner |
| Selected robots only | just the robots picked when it was registered |
Every robot of this tenant is the ordinary case: a machine inside the customer's network that may run whatever that customer's robots are. Selected robots only is for the machine that is special — the one that reaches the fiscal system, or the one with a licence dongle attached — where "runs everything" is wrong and the list is short and deliberate. Picking that scope makes the screen ask which robots.
A runner without Python never receives a Python robot
At start the agent looks for python3 on the PATH (or the interpreter given in --python) and tells the platform what it found. A robot whose runtime is Python is never assigned to a runner that has not declared Python 3.10 or newer: the execution stays PENDING with the reason "No available runner can run Python". Java robots are unaffected. Uploading Python packages (a .zip with flowia-robot.json) is already accepted when ROBOTS_PYTHON_ENABLED is on; see the "Python package" box under "Registering a new Robot". The Python column of the list shows which runners receive Python robots.
The runner's own log
Every row carries Log: what that machine's agent reported about itself — a refused handshake, a JAR digest that did not match, an execution collected, the child's exit code, a reconnection after an outage. It is what answers "why is my runner not picking up work" without access to the machine.
It records events, not routine: an idle agent asking for work every 25 seconds writes nothing. And what happened while the platform was unreachable does not arrive — the agent holds the last lines and sends them when it comes back, but a long silence shows up as silence.
The shared runner's log belongs to the platform
It names executions and main classes of every tenant it served, so only the platform administrator reads it. Your tenant's execution logs stay on the robot screen.
The list and each runner's state
| Column | What it shows |
|---|---|
| Name | the name given when it was registered, and the machine's Java version |
| Python | the Python version the agent found, and the machine's platform (e.g. linux_x86_64) — the one the package's wheels must be built for. Not available when the agent found no Python 3.10 or newer |
| Scope | what that runner accepts, and which robots when the list is explicit |
| Fingerprint | the public key's fingerprint, to check against what --init printed |
| Last seen | when the agent last polled |
| Status | online, offline, waiting for first connection, or revoked |
"Waiting for first connection" is normal, not a failure
Registration comes before the machine: you register the public key and only then start the agent — sometimes days later, sometimes on a machine that has not been provisioned yet. Until then the runner shows as Waiting for first connection, and leaves that state on its own as soon as the agent polls for the first time. There is nothing to fix: while any runner is in that state, the list refreshes itself every few seconds, with no page reload.
Revoked runners are left out of the list by default. When there is one, a Show revoked runners (N) switch appears to bring them back into view.
Replacing the key, and revoking
Rebuilt machine — run --init again on the new machine and paste the new key into Edit runner. Filling the key in revokes the old one by construction; there is no need to delete and register again.
Revoking — the Revoke button asks for confirmation and says what happens: the runner fails its next poll and stops receiving work, and an execution already running is not killed. Revoking kills nothing mid-flight; it only stops that runner from being picked from then on.
JAR Development (Native API)
The JAR loads into a URLClassLoader of its own, inside the child process, and talks to the platform only over RPC authenticated with an ephemeral token for that execution. If the robot hangs, eats all the memory or calls System.exit, the process that dies is its own; the platform records the failure and carries on.
What the sandbox bounds:
| Bound | What it means for tenant code |
|---|---|
| Environment variables | The process gets only the minimum the JVM needs to start. Database credentials and AI provider keys are not visible |
| Memory | Starts with -Xmx (512 MB by default, configurable); a runaway robot dies on its own |
| Time | On timeout the process is killed, not merely recorded as expired |
| Working disk | A working and temp directory for that execution only, deleted when it ends, failures included. A file in /tmp does not survive to the next execution |
| Reach | Only what RobotContext exposes, always within the robot's own tenant |
What the sandbox does not isolate yet: the disk
The robot process runs on the runner, as the same OS user as the runner agent that runs it — since 2026-08-27 no robot runs on the platform's machine any more —, and therefore with that user's file permissions. Environment, memory and run time are bounded; the filesystem is not.
If the installation's tenants represent distinct parties — your firm's own clients, say, one tenant each — and share a runner, that means code uploaded by one tenant can read whatever the agent's user can reach, including the agent's private key and what another tenant's execution left on the machine. This is not a theoretical limitation and there is no setting that turns it off.
Worth stating because the natural reading is the opposite: an installation belonging to a single company does not make this smaller. When each tenant is one of your clients, the boundary between them is a confidentiality obligation between distinct parties — stricter, not looser.
Real isolation requires robot execution to run in its own container — the other three routes do not work on this deployment model (dropping privileges needs the platform to run as root, an OS sandbox needs capabilities the managed runtime withholds, and SecurityManager was removed in Java 21). That work has not been implemented yet.
Until then: treat JAR upload as a permission of trust. There is no cross-tenant exposure only when tenants are divisions of one organisation. If each tenant is a different client, grant tenant ADMIN only to someone you would trust with read access to the server — in practice, only your own team.
Accessing data, CMS and process variables (JSONB)
Robots can interact richly with process instance data (using variables structured as JSON/JSONB) and also with Flow.IA's schemaless database (the CMS).
To build your robot, implement the FlowIaRobot interface:
import ia.flow.engine.FlowIaRobot;
import ia.flow.engine.RobotContext;
import java.util.Map;
public class MyRobot implements FlowIaRobot {
@Override
public void execute(RobotContext context) {
context.log("Scanning CMS...");
// Direct access to JSON Parameters provided in the UI
Map<String, String> params = context.getParameters();
// Native API Access: Consume or write to the current Tenant's CMS
context.getCms().getRecordsByCollectionSlug("invoices")
.forEach(record -> context.log("Processing invoice: " + record));
// Native API Access: Start new processes
context.getProcesses().startProcess("billing-workflow", Map.of("source", "robot"));
// Native API Access: Query organizational members
var members = context.getTenant().getMemberEmails();
context.log("Members notified: " + members.size());
// Native API Access: Upload files (e.g. attaching to a process instance or task)
byte[] pdfContent = new byte[]{ /* ... file bytes ... */ };
java.util.UUID attachmentId = context.getStorage().uploadAttachment(
"instance", "instance-uuid-here", "invoice.pdf", "application/pdf", pdfContent
);
context.log("Uploaded invoice attachment: " + attachmentId);
}
}The RobotContext is your isolated facade to Flow.IA. It guarantees that any operation (like getCms(), getProcesses(), or getStorage()) is strictly restricted to data belonging only to the active robot's Tenant, preventing a robot from leaking data from neighboring companies on the platform.
Where the annotation comes from
@RobotParameter is not published to any Maven repository: declare your own copy, in the same package and under the same name. The annotation's source and the reasoning are in Java Delegates → Where the annotation comes from — the only difference is that @RobotParameter has secret and @DelegateParameter does not.
Developing the Python package
A Python robot is a .zip shaped like this:
invoice-sync.zip
├── flowia-robot.json required
├── src/ the code (any layout; the entry point is resolved inside it)
│ └── invoice_sync/
│ ├── __init__.py
│ └── main.py def run(ctx): ...
├── requirements.txt optional
└── wheels/ optional: every dependency as a .whlThe manifest, flowia-robot.json:
{
"manifestVersion": 1,
"entryPoint": "invoice_sync.main:run",
"requiresPython": ">=3.10",
"sourceRoot": "src",
"parameters": [
{ "name": "erpUrl", "label": "ERP URL", "required": true },
{ "name": "erpToken", "label": "ERP token", "required": true, "secret": true }
]
}entryPointismodule:function. The function takes one argument, the context. A robot that returns has succeeded; an exception ends the execution asFAILED, andsys.exit(n)passes the codenthrough.requiresPythonaccepts only>=3.Xor>=3.X.Y, 3.10 at least. The robot's page shows that minimum next to the package version, and the execution only goes to a runner with that Python or newer.sourceRootis optional; without it the code is looked up at the package root.parametersbuilds the same configuration form that@RobotParameterbuilds for a JAR.
Dependencies travel with it, as wheels. Build the folder with pip download --only-binary=:all: -d wheels -r requirements.txt on a machine with the same platform as the runner (the Python column of the runners screen shows which). Because installation runs with no implicit dependencies, requirements.txt must also list the dependencies of the dependencies. Each line is just name or name==version: pip options, URLs and paths are refused by the runner. The runner installs without reaching the internet and without running install code; that is why a .tar.gz in wheels/ is refused on upload. A wheel built for another platform fails to install, with pip's own message in the execution log. What the runner needs, and the agent's Python options, are in Python robots: what the runner needs.
The context.
def run(ctx):
ctx.log("Starting CMS scan...")
erp_url = ctx.parameters["erpUrl"]
last = ctx.state.get("cursor")
for record in ctx.cms.records("customers"):
ctx.log("customer " + str(record.get("id")) + " sent to " + erp_url, "debug")
ctx.state.put("cursor", str(int(last or 0) + 1))ctx has the same surface as the Java RobotContext: ctx.tenant_slug, ctx.parameters, ctx.log(message, level="INFO"), ctx.cms.records(slug) and ctx.cms.create(slug, data), ctx.processes.start(key, variables) and ctx.processes.count_active(key), ctx.tenant.member_emails(), ctx.state.get/put/remove and ctx.storage.upload_attachment(type, id, name, content_type, content). A call the platform refuses (for example a feature the robot has no permission to use) raises RobotRpcError.
The robot logs whatever it wants with ctx.log: the two origins (what the robot logged and what the process printed) show up separately on the screen, as with Java robots. A robot that calls an AI model with the tenant's own key over the network spends on the tenant's account: the platform does not meter that usage.
SDK. With Python on for the installation, the robots screen (and a Python robot's page) offers Download Python SDK: RobotRunnerPython.zip, with the flowia_robot module and flowia_robot.testing.FakeContext, to run the robot on your computer without the platform. The zip carries no manifest: copy the flowia-robot.json example at the start of this section.
Turning Python on for the installation. Uploading the package requires the installation to enable Python robots (app.robots.python.enabled, ROBOTS_PYTHON_ENABLED), which is off by default. While it is off, the registration form shows the Python option as unavailable, naming the setting. The package has limits on entries and uncompressed size, and the refusals name the entry and what to do.