App Blueprint
This page shows how to build an IronFlock app so that it uses the platform the way it is meant to be used. The idea in one sentence: your containers run on every device and keep all of the app’s state in the app’s tables, the board is the app’s user interface, and the two never talk to each other directly. A web UI that a device serves itself reaches the users through a remote-access tunnel and appears inside the board.
The individual pieces are described in detail on their own pages. This page shows how they fit together.
The Parts of an App
An app runs on many devices at once, typically industrial PCs next to machines. It consists of:
| Part | What it is | Defined in |
|---|---|---|
| Edge code | A container, or a Docker Compose stack, on every device the app is installed on | Dockerfile or docker-compose.yml |
| SDK | The only link between your code and the platform: tables, realtime updates, device functions, files | IronFlock SDK (Python, JavaScript, Go) |
| Tables | The app’s state, in the project’s own database | data-template.yml |
| Board | The app’s central user interface, bound live to the tables, for all devices | board-template.yml |
| Parameters | Settings of one device or device group | env-template.yml |
| Remote access | Tunnels to ports your containers serve, such as a web UI | port-template.yml |
| AI agents | Agents for the IronFlock assistant, with your device functions as tools | ai-template.yml |
When a user installs your app in a project, IronFlock creates the app’s tables in that project’s database, installs its board, and opens a private messaging realm that connects the app’s containers on all devices, its tables and its board in realtime. The SDK joins that realm with the identity the device agent injects, so your code needs no address and no credentials.
The Rules
- The tables are the app’s state. Whatever the board, another device, a restarted container or an AI agent needs is stored in a table. A container keeps only what it can rebuild from the tables, such as an in-memory copy of its settings.
- The parts talk through the tables, not to each other. A device writes what it measures and the state it is in, and the board shows it. The board writes what users decide, and the devices apply it. Neither calls the other, so the board works while devices are offline, and devices keep working while nobody has the board open.
- One writer per table. Each table is written by the devices (each one about itself), by the board, or by one service. Say which in the table’s
description. Two writers of the same rows overwrite each other’s changes. - Desired state, not commands. To change what devices do, write the target into a table (a limit, a setpoint, a recipe, a schedule) and let every device apply the newest row. A device that was offline applies it when it comes back, and applying it twice changes nothing.
- Write on change, follow live, never poll. Write an entity’s row when one of its values changes, and measurements at their sample rate. Everything that reads follows the tables in realtime: the board’s bindings, table subscriptions on the devices, websockets in a device’s web UI. A timer that re-reads a table is always the wrong design.
- The board is the UI; a device’s web UI adds to it. Serve a web page from a device only for what a board cannot show: a machine’s own HMI, video, a high-rate live view, an operator screen at the machine. The board embeds it.
Where Each Kind of Data Goes
| Data | Where | Written by | Read by |
|---|---|---|---|
| Readings over time | A table with tsp | publishToTable, publishRowsToTable | Charts with time windows and aggregation |
| The current state of a device, machine or order | An entity table (maintainLatestFlagFor) | appendToTable: a row with the key, tsp and the changed columns | The latest filter |
| Limits, setpoints, recipes users set | A table the board’s form writes | The form | Every device, as shown below |
| Records users create, edit and delete | An entity table with a deleted column | The form, which sets the flag on delete | Lists and edit pages on the board |
| Errors and notices for operators | The error-logs table | reportError with a userMessage | The board’s toast widget |
| Images, documents, recordings | The app’s file storage | files.put | The file’s URL in a column, shown by a widget |
| Passwords and API keys | A secret: true column | Any table write | revealSecrets, verifySecret, in the app’s own containers only |
| One device’s installation: a machine’s address, a serial port | App parameters | The device’s or group’s app settings | Environment variables |
| Messages between your parts that nobody needs later | Your own topics | publish | subscribe |
| A request that needs one device’s answer now | A device function | registerDeviceFunction | callDeviceFunction, widget actions, AI tools |
Append or publish? appendToTable waits for a data backend that is restarting, and tells you when it refuses a row. publishToTable waits only for the connection, so a row published while the data backend restarts is lost. Append state changes and records; publish high-rate readings.
Parameters or a settings table? Parameters belong to a device or a device group: whoever installs or configures the device sets them in the app settings, and your code reads them from its environment. Use them for what differs from one installation to the next. What users change while the app runs — limits, recipes, schedules, anything that applies to many devices — belongs in a table that the board writes. The board is where users work with the app, every device follows a change at once, and the table keeps the history of who changed what and when.
Following What the Board Writes
A device applies board-written state, such as a limit set on the board, in three steps:
- Subscribe to the table, before reading it, so that no row falls between the read and the subscription.
- Read the newest row once at start, with the
latestfilter, and apply it. - Apply a row only if it is newer (
tsp) than the one in force. A row saved just as your program starts can arrive from the read and from the subscription.
Python
await ironflock.subscribe_to_table("settings", lambda row, *args, **kwargs: apply(row))
rows = await ironflock.getHistory("settings", {"limit": 1, "filterAnd": [{"latest": True}]})
for row in rows or []:
apply(row)That is all, also for devices that go offline. The SDK keeps the connection out of your code: it reconnects, restores your subscriptions and device functions, and then delivers to each table subscription the rows stored while the device was offline, oldest first, before the rows that arrive live. A limit saved while a device was offline reaches it as soon as it is back. The catch-up goes by tsp: it delivers the rows newer than the newest one your handler received, at most the newest 10,000. Its read waits for the data backend like any history read, up to the reconnect window; when it fails, those rows are not delivered and a warning is logged. The catch-up is part of the SDK from version 1.10.0.
Commands, Device Functions and Topics
Prefer state. Most things users “command” are desired state: a setpoint, on or off, a recipe. Write them into a table, as above.
Commands that must run once, such as printing a label or starting a cleaning cycle, are rows with an id in a table the board writes. The device records the ids it has done, in a table of its own, so a command it receives twice runs once.
Device functions are procedures a device registers with registerDeviceFunction. Use them when the caller needs the device’s answer right now:
- AI agents: a tool in
ai-template.ymlcalls the function on the device the assistant picks. See AI Agents & Tools. - Switch and SCADA widgets on the board call them while the device is online.
- Your own containers and devices call each other with
callDeviceFunction.
Return readable text or small JSON from a device function, and return an expected failure (a machine that is switched off) as a result instead of raising an error.
Topics carry messages between your own parts that nobody needs later: publish and subscribe on names in your own namespace, such as myapp.events. The board cannot subscribe to topics: whatever it shows must be in a table. Messages published while a device is offline are not stored, so use a table for anything that must arrive.
The Container on the Device
What the device agent gives your container, and what it expects from it:
- Identity: the agent sets
DEVICE_KEY,DEVICE_NAME,DEVICE_SERIAL_NUMBER,SWARM_KEY,APP_KEY,APP_NAME,ENV(DEVorPROD),TUNNEL_DOMAIN,DEVICE_LAN_IPandDEVICE_PORT_FOR_<port>, plus the app’s own credential for the SDK. The SDK reads what it needs; never hard-code any of these values. - Parameters: a production install gets the values users set, and each parameter’s
defaultValuefor the rest. A development run gets the default values only. Give parameters specific names (PLC_HOST, notHOST). - Storage: a single-image app gets
/data, private to the app and kept across restarts and updates (it is deleted with the app), and/shared, shared by all apps on the device. Keep the app’s state in tables anyway, and use these folders for files that belong to the device, such as a downloaded model or a cache. - Hardware: a single-image container runs privileged, with the device’s
/dev. - Logs: everything your program writes to stdout and stderr is the app’s log in IronFlock. Write it unbuffered (
python -u). - Start: the container must be running 30 seconds after it starts, or the start counts as failed.
- Crashes: when a production app exits, the agent starts it again, waiting longer after each failure. A development run is not restarted. Handle expected failures, such as a sensor that does not answer, in your program, and let it exit on errors it cannot recover from.
- Stop: your program is the container’s main process (PID 1), which ignores the stop signal SIGTERM unless it handles it. Exit on SIGTERM, or stopping the app waits until Docker kills it. Start the program in exec form (
CMD ["python", "-u", "main.py"]), not through a shell. - Development runs: running the app from the Cloud IDE is a development run (
ENVisDEV). It writes to the app’s development tables, gets the parameters’ default values and no remote-access tunnels.
Docker Compose
Use a docker-compose.yml when your app runs several processes, or bundles third-party services such as an MQTT broker or a database next to your own code. See Docker & Compose for the basics. Compared with a single image:
- Every service gets the injected environment. The agent gives each service its own environment file with the variables above; an
env_file:you write is replaced. A${VAR}in the file resolves against the injected values. - No
/data. Services get no app folder. Keep files in named volumes: they survive stops and updates, and are deleted when the app is removed from the device. - Published ports are remapped. Each published port gets a free host port on all interfaces of the device, and
DEVICE_PORT_FOR_<port>tells your code the real one. Inport-template.yml, use the host port as written in the compose file. Publish only what users should reach, and let your services talk to each other over the compose network by service name. A host port written as${VAR}is not managed: it gets no remapping and no tunnel. - Nothing is added. The agent grants compose services no privileges and no devices. Declare
privileged,devicesordevice_cgroup_rulesyourself. Adevices:entry names hardware that must exist, or the container does not start;device_cgroup_rulestogether with a/devvolume works on every device. - No
profiles:. A service with profiles is left out of the release. - The same start rule. Within 30 seconds every container must be running or have exited with code 0, and at least one must be running. Give long-running services
restart: unless-stopped.
Web UIs on the Device
A web server in your container becomes reachable from anywhere through the remote-access tunnel of its port, and the board shows it:
- Serve on a fixed port (bound to
0.0.0.0) and declare it inport-template.ymlwithprotocol: http. A production install switches the tunnel on and serves it over HTTPS, websockets included. - Write the tunnel’s URL into a table. The SDK’s
getRemoteAccessUrlForPort(port)gives the URL. Write it into the device’s row of a device table at start. Only production installs have tunnels, so a development run writes no URL. - Embed it in the board. Bind the Embed widget’s URL to that column, on a page per device that filters by the device. In the UI Builder this is a data binding like any other.
For the page itself:
- Who can open it: an http tunnel is private. Only people who are signed in to IronFlock and have access to the device can open it, unless a user switches the port to public. On the device’s own network the page is open to everyone, so show nothing there that the plant network may not see, and require a login of your own for anything that changes the machine’s state.
- Relative URLs: load assets, call your API and open websockets relative to the page, so it works on the device’s network, through the tunnel, and behind a reverse proxy.
- Push, never poll: send updates over a websocket.
- No platform credentials in the browser: the page talks to its own server in the container, and that server holds the SDK connection.
To show the web HMI of a machine on the plant network instead of a page of your own, reverse-proxy it from your container. See Embedding External HMIs.
Several Devices
- Every row carries its device: IronFlock stores the key of the device that wrote a row in
device_key. Key per-device tables bydevice_key, store the device’s name in a column withpath: kwargs.DEVICE_NAME, and split charts by that name. - A device list and a page per device: a side navigation built from the device table, with one page that works for every device and filters its widgets by the device in the route. See Building Boards.
- Settings per device or per machine: key the settings table by a column that names the target (rows the board writes carry no device of their own), and fill it in the form on the target’s page. Each device applies the newest row for its own key.
- A device that leaves keeps its rows. If users should be able to remove it from the lists, give the device table a
deletedcolumn and the board a form that sets it.
Checklist
- All state is in tables, each with one writer named in its description, and every column the code writes is declared in
data-template.yml. - Every row has a
tsp, and state rows are written on change, with only the key and the changed columns. - Board-written state is followed in the three steps above, with SDK 1.10.0 or later, and the code never deals with the connection or its sessions.
- Table writes in long-running loops handle the error a write raises when the platform has been unreachable for a minute, and expected failures are reported with
reportErrorand auserMessage. - The program exits on SIGTERM, starts in exec form and logs unbuffered.
- Nothing polls: the board binds, devices subscribe, web UIs push.
- Each port your app serves is declared in
port-template.yml, its URL comes fromgetRemoteAccessUrlForPortand is written to a table, and the board embeds it from there. - A compose app has no
env_file:, noprofiles:and no${VAR}host ports, keeps files in named volumes, and publishes only the ports users need.
Start From the Starter App
The IronFlock plugin for Claude Code contains a starter app that follows this blueprint: a program that reads a machine on every device, keeps each device’s state and readings in tables, applies the limit users set on the board, and serves a live view that the board embeds on each device’s page. Claude Code creates a new app from it and adapts it to your use case.