IronFlock SDK
The IronFlock SDK lets your edge applications interact with the IronFlock platform. It comes in three languages — Python, JavaScript and Go — with the same functionality, documented side by side below. It handles authentication automatically when running on a registered device and provides functions for publishing data, querying history, calling remote procedures across devices, and updating device metadata.
| SDK | Package | Requires |
|---|---|---|
| Python | ironflock on PyPI | Python 3.8+ |
| JavaScript | ironflock on npm | Node.js 18+ or modern browser |
| Go | github.com/RecordEvolution/ironflock-go | Go 1.27.2+ |
Installation
Python
pip install ironflockOr add ironflock to your app’s requirements.txt.
Quick Start
Python
import asyncio
from datetime import datetime, timezone
from ironflock import IronFlock
async def main():
while True:
await ironflock.publish_to_table("sensordata", {
"tsp": datetime.now(timezone.utc).isoformat(),
"temperature": 22.5,
"humidity": 60
})
await asyncio.sleep(5)
ironflock = IronFlock(mainFunc=main)
ironflock.run()When used inside an IronFlock app container, the SDK reads connection credentials from the environment automatically — no manual configuration needed. Every row you write to a table carries a tsp, its timestamp (see publishToTable).
Constructor Options
Python
ironflock = IronFlock(
mainFunc=main, # async function to run after connecting
serial_number="abc123", # override device serial (optional)
reconnect_window=60, # seconds a table operation rides out a platform restart (optional)
)| Parameter | Description |
|---|---|
mainFunc | An async function that runs once the connection is established |
serial_number | Override the device serial number sent with publications and table writes. Defaults to DEVICE_SERIAL_NUMBER env var. It does not change the identity the connection authenticates with while the device agent injects a per-app credential |
reconnect_window | Seconds a table operation waits for the platform to come back after a restart before it raises. Defaults to 60; 0 turns it off (see Connection Reliability) |
Publishing Data
publishToTable / publish_to_table
Publishes a data record to a fleet table. The table name must match a table defined in your app’s data-template.yml. The SDK automatically routes the data to the correct project database.
Python
from datetime import datetime, timezone
await ironflock.publish_to_table("sensordata", {
"tsp": datetime.now(timezone.utc).isoformat(),
"temperature": 22.5,
"humidity": 60,
"device_id": "sensor-001"
})The data backend reads the row from the first argument: a dict in Python, the first element of the array in JavaScript, the first positional argument in Go (a map or a struct). Extra keyword arguments — an ironflock.Kwargs value among the arguments in Go — are read only for columns the data template maps to them (kwargs.<name>).
Every row needs a tsp. Every table has a mandatory tsp column, the row’s timestamp — e.g. an ISO 8601 string, or in Go a time.Time, which is sent as RFC 3339 in UTC — which the data backend reads from the row unless the data template maps it elsewhere. It drops a published row without one, with nothing but an entry in the app’s error-logs table to show for it — the acknowledged publish looks successful. A row it refuses for lack of storage is dropped without even that. Use appendToTable / append_to_table when you need to know whether a row was stored.
Partial rows on entity tables. On a table with an entity key (maintainLatestFlagFor), a row can carry just the entity key, tsp and the columns that change. A column the row leaves out keeps the entity’s previous value; a column sent as None / null / nil is cleared. Whether a field counts as left out depends on how you build the row:
- Python: a key whose value is
Noneis sent as null and clears the column. Remove the key from the dict to keep the old value. - JavaScript: a property whose value is
undefinedis dropped from the payload, so it keeps the old value. Set it tonullto clear the column. - Go: a struct field tagged
omitemptyoromitzerois left out when it is empty, so it keeps the old value. Otherwise anilpointer field, like anilvalue in anironflock.Row, is sent as null and clears the column.
The same holds for all four write methods. On a table without an entity key, a column the row leaves out is stored as NULL.
appendToTable / append_to_table
Appends data to a fleet table using a remote procedure call instead of pub/sub. Use this when you need confirmation that the data was persisted: a refused row is an error.
Python
result = await ironflock.append_to_table("sensordata", {
"tsp": datetime.now(timezone.utc).isoformat(),
"temperature": 22.5,
"humidity": 60
})The row needs a tsp, as for publishToTable. The data backend refuses a row without one with wamp.error.runtime_error and no reason; the SDK’s error then adds “the data backend refused the row without a reason — a missing tsp is the usual cause; see the error-logs table”. The typed refusals, such as a full disk on an appliance, are listed under Data backend refusals.
publishRowsToTable / publish_rows_to_table
Publishes many rows in a single message (bulk insert) to a fleet table. The platform inserts the whole batch atomically (all-or-nothing) in one operation. Use this for high-frequency data where one round-trip per row would be too costly. Like publishToTable, this is fire-and-forget — the acknowledgement confirms delivery to the router, not the database insert.
Python
await ironflock.publish_rows_to_table("sensordata", [
{"tsp": "2024-01-15T10:30:00.000Z", "temperature": 22.5},
{"tsp": "2024-01-15T10:30:01.000Z", "temperature": 22.7},
])The second argument is a non-empty list of row objects to insert, each with its own tsp — in Go a slice of rows ([]ironflock.Row, []map[string]any or a slice of structs). A batch the data backend refuses — because a row lacks its tsp, say — is dropped without a word to the publisher, as with publishToTable.
appendRowsToTable / append_rows_to_table
Appends many rows in a single remote procedure call (bulk insert) to a fleet table. The platform inserts the whole batch atomically (all-or-nothing): if any row is invalid the entire batch is rejected and nothing is persisted. Prefer this over publishRowsToTable / publish_rows_to_table when you need the insert outcome. Every row needs its own tsp; the refusals are those of appendToTable, including the hint for a batch refused without a reason.
Python
result = await ironflock.append_rows_to_table("sensordata", [
{"tsp": "2024-01-15T10:30:00.000Z", "temperature": 22.5},
{"tsp": "2024-01-15T10:30:01.000Z", "temperature": 22.7},
])
# result -> {"success": True, "count": 2}reportError / report_error
Reports an application error into your fleet’s error-logs table. This is a convenience wrapper over publishToTable / appendToTable: it stamps the row with source: "app", a severity level, and a timestamp, then writes it like any normal table row. The error lands in the same error-logs table that fleetdb system errors use (tagged source: "system"), so it is queryable with getHistory, streamable with subscribeToTable / subscribe_to_table, usable in board-templates, and delivered in realtime on transformed.error-logs — without firing the platform’s system-error toast.
Python
# Fire-and-forget (default): publishes to the error-logs table
await ironflock.report_error("Sensor read timed out", level="warn")
# Pass an exception to capture its traceback (falls back to the message)
try:
risky_operation()
except Exception as err:
await ironflock.report_error(err)
# Use the append RPC when you want to await the insert outcome, and show the
# operator a friendlier line than the raw error
await ironflock.report_error(
"Calibration failed: offset 0.42 out of range",
append=True,
user_message="Please recalibrate the sensor",
)The row is { tsp, msg, user_message, source: "app", level }: msg holds the technical text (a traceback or stack), user_message the line the board’s error toast shows — the user message you pass, or the message itself without one.
Parameters:
| Parameter | Type | Description |
|---|---|---|
error | str / string or exception / Error | The error message, or an exception whose traceback/stack (or message) is recorded |
level | str / string, optional | Severity: "error", "warn", "info" or "debug". Defaults to "error" |
append | bool / boolean, optional | When true, use the append RPC (returns the insert outcome). Defaults to false (fire-and-forget publish) |
tsp | str / string, optional | ISO-8601 timestamp override. Defaults to the current time |
user_message / userMessage | str / string, optional | Operator-facing text, stored as user_message. Defaults to the recorded message |
In Python the options are keyword arguments (
report_error(error, level=..., append=..., tsp=..., user_message=...)); in JavaScript they are passed via an options object (reportError(error, { level, append, tsp, userMessage })); in Go via a struct (ReportError(ctx, errOrMsg, ironflock.ReportErrorOptions{Level, Append, Tsp, UserMessage})), whereerrOrMsgis anerroror a string,Levelone ofironflock.LevelError,LevelWarn,LevelInfo,LevelDebug, and the returned*ResultisnilunlessAppendis set.
publish
Publishes a message to a WAMP topic of your own. Use this for custom messaging or events that don’t map to a database table. The router reserves some names for the platform — see URIs an App May Use.
Python
await ironflock.publish("com.myapp.alerts", {
"level": "warning",
"message": "Temperature threshold exceeded"
})Querying Historical Data
getHistory
Retrieves historical data from a fleet table or transform: the newest rows that match, in ascending tsp order. Supports filtering, time ranges, and pagination.
Python
# Simple query
data = await ironflock.getHistory("sensordata", {"limit": 100})
# Query with time range and filters
data = await ironflock.getHistory("sensordata", {
"limit": 500,
"offset": 0,
"timeRange": ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"],
"filterAnd": [
{"column": "temperature", "operator": ">", "value": 20},
{"column": "humidity", "operator": "<=", "value": 80}
]
})
# Current value(s) only: the "latest" marker returns the newest row per entity
current = await ironflock.getHistory("sensordata", {
"limit": 100,
"filterAnd": [{"latest": True}]
})
# One field of a large json column: the column comes back pruned to its paths
readings = await ironflock.getHistory("sensordata", {
"limit": 100,
"columns": ["json_data"],
"columnPaths": ["json_data.sensor.temp"],
})Query parameters:
| Field | Type | Description |
|---|---|---|
limit | int / number | Maximum rows to return (1–10,000, required; 1–3,000 for a transform) |
offset | int / number | Offset for pagination |
timeRange | list / array | [start, end] — ISO date-time strings or epoch-millisecond numbers, never mixed; None / null for an open end. A legacy {"start": ..., "end": ...} object is converted to the pair |
filterAnd | list / array | AND-ed filter entries: conditions, groups, and/or the latest marker (see below) |
columns | list / array | Columns to return (optional). tsp, device_key and authid are always included; omit for all columns |
columnPaths | list / array | Prunes json columns (optional): JSON paths in the notation filters use ("json_data.a.b", "json_data['key']"). A json column listed in columns comes back holding only the union of its paths; one without a path here comes back whole. Ignored by transforms and by data backends older than fleetdb v1.0.52 |
In Go these are the fields Limit, Offset, TimeRange, FilterAnd, Columns and ColumnPaths of ironflock.TableQueryParams. TimeRange is a *ironflock.TimeRange{Start, End}: each bound a time.Time, an ISO 8601 string, an epoch-milliseconds number, or nil for an open end (ironflock.Between, Since and Until build one from time.Time values). FilterAnd is a []ironflock.Filter. There is no legacy object form.
Filter operators: =, !=, <>, >, <, >=, <=, LIKE, ILIKE, NOT LIKE, NOT ILIKE, IN, NOT IN, IS NULL, IS NOT NULL
Each filter condition is an object with column, operator, and value keys — in Go, built with ironflock.Where(column, operator, value), ironflock.IsNull(column) and ironflock.IsNotNull(column). Operators are matched in any case, with surrounding spaces ignored, and sent in the form listed above — the only one the data backend accepts — so "like" goes out as "LIKE", inside groups too. An operator outside the list is sent as given, with a warning. IS NULL and IS NOT NULL take no value and are the only way to ask about NULL: every other operator excludes a row whose column is NULL, exactly as in SQL. IN and NOT IN take a list (a slice in Go). LIKE is case-sensitive; use ILIKE to match case-insensitively.
Combining with OR. Entries of filterAnd are AND-ed. A group entry combines its own entries — conditions, the latest marker or further groups — with one operator, "AND" or "OR":
Python
# Rows that are not soft-deleted: deleted IS NULL OR deleted = false
"filterAnd": [
{
"combinator": "OR",
"filters": [
{"column": "deleted", "operator": "IS NULL"},
{"column": "deleted", "operator": "=", "value": False},
],
}
]A data backend that predates groups (fleetdb before v1.2.0) rejects this shape rather than applying part of it, so it fails loudly instead of quietly returning rows the filter excludes.
Reading current values. A {"latest": true} entry in filterAnd (ironflock.Latest() in Go) is not a filter condition but a mode switch: the data backend returns only the newest row per entity, derived in SQL from the entity key the table declares with maintainLatestFlagFor. A table without an entity key returns its single most recent row.
Other conditions combine with the marker as you would expect: conditions on entity-key columns narrow down which entities are returned, while all other conditions and timeRange are applied to the resulting latest rows. So combining {"latest": true} with a deleted filter hides deleted entities instead of resurfacing their previous row.
Earlier versions of IronFlock stored a physical latest_flag column. It no longer exists — a legacy latest_flag = true filter is still accepted and treated as the marker, but new code should use {"latest": true}. The latest marker is not available in getSeriesHistory.
Time ranges. The data backend reads a string bound with JavaScript’s Date, so write an ISO 8601 date or date-time (2026-07-01, 2026-07-01T12:30:15.250Z) and separate date and time with T. The SDKs refuse a space between date and time in the years 0–99, which Date misreads: "0001-01-01 00:00:00" — what Python’s str(datetime.min) gives — would be read as 2001. A time without a zone is read as UTC. A numeric bound is epoch milliseconds and must be finite: NaN and ±Infinity would reach the data backend as an open bound and silently drop the time filter, so the SDKs refuse them before sending. In Go, a time.Time bound is sent as RFC 3339 in UTC, and a string bound must be one of the ISO 8601 forms Date reads correctly — a date, or a date and a time of day with an optional Z or UTC offset (+02:00 or +0200); other strings, such as the basic format 20260701T123000Z, an offset of hours only, or a day the month does not have (2026-02-30), fail with ironflock.ErrInvalidArgument before anything is sent.
Transforms. A transform (a data template’s SQL view, see Transform Tables) is read differently from a table. The data backend applies limit — at most 3000, a larger one is refused with URI_INVALID_LIMIT — offset and filterAnd only. It ignores timeRange, columns, columnPaths and the latest marker, and drops a filter on a column the transform does not declare (a transform has no implicit tsp, device_key or authid), which widens the result. Its rows come in the view’s own order, reversed: you get the newest rows, oldest first, only when the view orders newest first. Bound a transform’s time range in its SQL.
Large results. A result too large for one message of the data backend (8 MiB of JSON) arrives in chunks, which the SDK reassembles in memory: you get the rows of a single read, in the same order. Chunks that do not add up — one missing or duplicated, or a row count that differs from the data backend’s summary — fail the read instead of returning part of the rows. A single row over 8 MiB, or a result over the data backend’s 1 GiB guard, is refused with URI_RESULT_TOO_LARGE: lower limit, narrow timeRange, select fewer columns, or prune with columnPaths.
getSeriesHistory / get_series_history
Retrieves down-sampled time-series data from a fleet table: columns aggregated per time bucket (e.g. hourly averages). Ideal for charts spanning long time ranges. Available for tables (not transforms), on data backends from fleetdb v1.0.58 on.
Python
from datetime import datetime, timedelta, timezone
from ironflock import SeriesMetric
now = datetime.now(timezone.utc)
avg = SeriesMetric(ref="temperature", method="AVG") # AVG, SUM, COUNT, MIN, MAX, FIRST, LAST
series = await ironflock.get_series_history("sensordata", {
"metrics": [avg, {"ref": "humidity", "method": "MAX"}],
"bucketMs": 3_600_000, # one-hour buckets; omit to divide the range into `limit` buckets
"limit": 24, # the bucket budget, 1–10000
"timeRange": [(now - timedelta(hours=24)).isoformat(), now.isoformat()], # required, with a start
"groupBy": ["device_key"],
})
for row in series:
print(row["tsp"], row["device_key"], row[avg.column]) # avg.column is "AVG:temperature"Query parameters:
| Field | Type | Description |
|---|---|---|
metrics | list / array | The aggregates computed per bucket, at least one: {"ref": ..., "method": ...}. ref is a column, or a JSON path into a json column ("json_data.temp"); method is "AVG", "SUM", "COUNT", "MIN", "MAX", "FIRST" or "LAST". Each metric comes back under "<METHOD>:<ref>", which must fit 63 bytes |
bucketMs | int / number | Bucket width in whole milliseconds, at least 1000 (optional). Omit it to divide the time range into limit buckets |
limit | int / number | The bucket budget (1–10,000) — not a row limit |
timeRange | list / array | [start, end] — ISO date-time strings or epoch-ms numbers. Required, with a start; None / null as the end means now. The end must lie after the start |
groupBy | list / array | Columns that split every bucket into a row per group (optional) |
filterAnd | list / array | AND-ed conditions and groups, as for getHistory (optional). The latest marker is refused here, also inside a group; use getHistory to read current values |
Rows. Each row is one bucket — of one group, with groupBy. tsp is the bucket’s start in epoch milliseconds, each groupBy column comes back under its own name, and each metric under "<METHOD>:<ref>" (SeriesMetric.column in Python, seriesColumn(metric) in JavaScript, SeriesMetric.Column() in Go), e.g. row["AVG:temperature"]. The rows ascend by tsp; a bucket without rows is absent (no gap filling). AVG and SUM take numeric columns, MIN and MAX strings and timestamps too; FIRST and LAST give the value of the bucket’s earliest and latest row; COUNT counts the values that are not null (of tsp: the rows). A large result is reassembled from chunks, as for getHistory.
Buckets and an open end. limit counts buckets, not rows. Without bucketMs the time range is divided into limit buckets. With it, the data backend widens the buckets to a multiple of bucketMs when the range holds more than limit of them — and an open end reaches up to the data backend’s own now, a little after yours. The last 24 hours with an open end hold 25 one-hour buckets, so limit: 24 gets two-hour ones. Bound the end, as in the example, or allow a bucket more: with bucketMs and an open end, limit + 1 buckets can come back.
The SDKs check what the data backend would refuse before sending: at least one metric, non-blank refs, known methods, column names within 63 bytes, a start, an end after the start, and bucketMs as an integer of at least 1000 (in Go, Bucket of at least 1 second, in whole milliseconds). The data backend refuses what it cannot answer with URI_INVALID_TIME_RANGE, URI_INVALID_METRIC, URI_INVALID_GROUP_BY or URI_SERIES_TOO_MANY_GROUPS (more than 50,000 rows) — see Data backend refusals.
Deprecated form. SDK releases up to 1.9.0 sent
metricsas column names plus a top-levelmethod({"metrics": ["temperature"], "method": "AVG", ...}), which data backends have refused since fleetdb v1.0.58. That form still works: the SDK sends one{ref, method}metric per column, warns (aDeprecationWarningin Python, a console warning once per process in JavaScript), and renames the result columns back to the column names, so old code keeps readingrow["temperature"]— unless the row already has a column of that name, such astspforCOUNToftsp. Move to{ref, method}metrics and readrow["AVG:temperature"]. The Go SDK has only the current form: a method per metric (SeriesMetric).
Subscribing to Data
subscribeToTable / subscribe_to_table
Subscribes to real-time updates on a fleet table. The handler is called whenever the data backend stores a new row, with the row as stored — values typed to the data template’s columns, secret columns masked — as its first argument. Rows written via the bulk insert path (publishRowsToTable / appendRowsToTable) are delivered to your handler one at a time, so handler code stays the same regardless of how the data was written.
Python
def on_sensor_data(*args, **kwargs):
row = args[0]
print("New reading:", row["tsp"], row["temperature"])
await ironflock.subscribe_to_table("sensordata", on_sensor_data)A transform has no rows of its own: on every tick of its schedule the data backend publishes the whole view — at most 3000 rows, in the view’s order reversed — as one event whose first argument is the list of rows. event_rows(args[0]) in Python, eventRows(args) in JavaScript and ev.Rows() in Go read both shapes: a table’s row gives [row], a transform’s list the list ([] for an empty view), anything else None / undefined / nil. In Go, ev.Row() is nil for a transform’s event.
Python
from ironflock import event_rows
def on_hourly(*args, **kwargs):
for row in event_rows(args[0]) or []:
print(row)
await ironflock.subscribe_to_table("hourly_averages", on_hourly)subscribe
Subscribes to a WAMP topic of your own for custom real-time messaging. For table rows use subscribeToTable / subscribe_to_table: the router refuses subscriptions to the raw table write topics (see URIs an App May Use).
Python
def on_alert(*args, **kwargs):
print("Alert received:", args, kwargs)
await ironflock.subscribe("com.myapp.alerts", on_alert)Secret Columns
A column declared secret: true in the app’s data template is encrypted at rest. No ordinary read path returns the plaintext, not even to the app that wrote it:
| Read path | What a secret column looks like |
|---|---|
getHistory, subscribeToTable / subscribe_to_table, and everything else over the message router | the placeholder __secret__, exported as SECRET_PLACEHOLDER (ironflock.SecretPlaceholder in Go) |
| SQL access (the FleetDB Access Postgres login) | the stored ciphertext, ifsec:1:… |
revealSecrets / reveal_secrets | the decrypted plaintext |
NULL is passed through as NULL on every path, so “set but hidden” stays distinguishable from “never written”.
On an entity table, read-modify-write is safe: republishing a row that still carries the __secret__ placeholder — or omitting the secret column altogether — keeps the stored secret instead of overwriting it, and an explicit null clears it. See Secret Columns in the data backend guide for the exact rules.
Python
from datetime import datetime, timezone
# Written like any other column — the data backend encrypts it on insert
await ironflock.publish_to_table("credentials", {
"tsp": datetime.now(timezone.utc).isoformat(),
"device_id": "d-1",
"api_token": "hunter2",
})
# A normal read redacts it
rows = await ironflock.getHistory("credentials", {"limit": 1})
# [{"tsp": "...", "device_id": "d-1", "api_token": "__secret__"}]Both functions below are callable only from the app’s own containers. The message router denies them to every browser role and to consuming apps, which is why a ConsumedApp handle has no equivalent: another app’s secret columns arrive redacted and stay that way.
revealSecrets / reveal_secrets
Reads rows of one of your own tables with its secret columns decrypted.
Python
rows = await ironflock.reveal_secrets("credentials", {
"limit": 1,
"filterAnd": [{"column": "device_id", "operator": "=", "value": "d-1"}],
})
# [{"tsp": "...", "device_id": "d-1", "api_token": "hunter2"}]The query parameters are the same as getHistory, with one difference: limit may not exceed 100. That ceiling exists to stop a single call decrypting a whole table, and the data backend rejects a larger value rather than quietly clamping it.
The data backend answers at most 30 reveals a minute per app credential on a device; beyond that the call fails with URI_RATE_LIMITED. Reveal a secret once and keep it, rather than on every use. A table without secret columns is refused with URI_NOT_A_SECRET_COLUMN, a filter on a secret column with URI_SECRET_COLUMN.
verifySecret / verify_secret
Checks a candidate value against a secret column without revealing it — the password-check counterpart of the reveal function. The comparison runs inside the data backend, in constant time, and only the answer comes back.
Python
ok = await ironflock.verify_secret(
"credentials", "api_token", submitted_token,
{"limit": 1, "filterAnd": [{"column": "device_id", "operator": "=", "value": "d-1"}]},
)
if ok:
...Parameters:
| Parameter | Type | Description |
|---|---|---|
tablename | str / string | The table to check against |
column | str / string | The secret column to compare |
candidate | str / string | The plaintext being tested. Compared server-side and never written to the table |
query_params / queryParams | SecretQueryParams | Which rows take part. Defaults to the most recent row; limit may not exceed 100 |
In Go the parameters are positional strings after the context, and the query is a *ironflock.TableQueryParams (nil for the most recent row).
JavaScript returns { match, checked } and Go a *ironflock.SecretVerifyResult with Match and Checked; Python returns a plain bool. Where you have checked, it is worth using: it separates a wrong candidate (checked > 0) from a selector that matched no rows at all (checked === 0), which are very different failures to report to a user.
Because every row is encrypted under its own random value, two rows holding the same secret store different ciphertext. That is why an = filter on a secret column cannot work — the data backend refuses any filter on one with URI_SECRET_COLUMN — and this function exists instead.
The data backend answers at most 120 checks a minute per app credential on a device; beyond that the call fails with URI_RATE_LIMITED. For a check on every request (an API key, say), cache a positive outcome briefly. A column that is not a secret column is refused with URI_NOT_A_SECRET_COLUMN.
Cross-App Data Access
Read another app’s fleet data from within your app, in the same project. Your app must declare the provider app in the consumes: section of its own data-template.yml (or hold the wildcard grant, below), and the project user must grant access. Access is read-only: you can query history and subscribe to real-time rows of the tables and transforms the provider shares, but you cannot write to them. Consumed-app connections are cached per app and closed automatically when your instance stops.
The connection needs the per-app credential the device agent injects (APP_AUTH_ID, APP_AUTH_SECRET). The platform refuses the legacy device credential — the serial-number fallback of an app that got no per-app credential, e.g. under an older device agent — on another app’s realm (NOT_AUTHORIZED, with a message that says so), unless the provider app runs on the same device, whose realm it then joins with that app’s own full rights rather than read-only.
If your app holds the wildcard grant (consumes: [{ app: "*" }]), you can discover and open providers dynamically with listConsumableApps / list_consumable_apps and connectToAllApps / connect_to_all_apps (below).
connectToApp / connect_to_app
Opens a read-only connection to another app’s data backend and returns a handle. The handle exposes getHistory / get_history, subscribeToTable / subscribe_to_table and getSeriesHistory / get_series_history (tables only) — the same querying and subscribing you use on your own tables, with the same parameters and results — plus close and the shared tables / transforms catalogs.
Python
# Open a read-only handle on another app's data backend
weather = await ironflock.connect_to_app("weather-app")
# Inspect what the provider shares
print([t["tablename"] for t in weather.tables])
# Query history and subscribe, just like your own tables
rows = await weather.get_history("forecasts", {"limit": 100})
def on_forecast(*args, **kwargs):
print("New forecast:", args)
await weather.subscribe_to_table("forecasts", on_forecast)Parameters:
| Parameter | Type | Description |
|---|---|---|
app_name / appName | str / string | Provider app name, as declared in your consumes: section |
stage | str / string, optional | Provider stage: "dev" or "prod". Defaults to your app’s own stage |
on_error / onError | callable, optional | Called with a CrossAppAccessError when the open connection is denied for good (e.g. the grant is later revoked — see below for when a denial counts as final) |
If access is denied or misused, a CrossAppAccessError is raised (Python) / thrown (JavaScript) / returned (Go, a *ironflock.CrossAppAccessError to test with errors.As) with a code field (Code in Go, with constants such as ironflock.CodeNoGrant): NO_GRANT, PROVIDER_NOT_INSTALLED, UNKNOWN_APP, PRIVATE_TABLE, SECRET_COLUMN (a query that filters on — also inside a group — or selects a column the provider marks secret), or NOT_AUTHORIZED (the grant was revoked or could not be verified just then — or your app presented the legacy device credential, in which case the message says so).
A denial while connectToApp / connect_to_app waits fails the call at once. Once a handle is open, a denial counts as final only when it persists — 3 refusals or more since the connection was last up (the router closing the session for an auth reason counts as one, as does each refused reconnect), the first at least 60 seconds ago — because the platform refuses the same way while it briefly cannot verify access. Until then the handle keeps reconnecting, and recovers if a reconnect succeeds. Once the denial is final, on_error / onError / OnError is called and the handle is evicted: the next connectToApp / connect_to_app / ConnectToApp opens a fresh connection. The handle’s reads and subscriptions fail at once from then on, with the error on_error / onError got — in Go, with an error wrapping wamp.ErrStopped.
In Python,
stageandon_errorare keyword arguments; in JavaScript they are passed via an options object (connectToApp(appName, { stage, onError })); in Go via a struct (ConnectToApp(ctx, appName, ironflock.ConnectToAppOptions{Stage, OnError})), whereOnErroris afunc(*ironflock.CrossAppAccessError). In Go,ctxbounds only the caller’s wait: concurrent calls for the same app share one attempt, andClose(ctx)closes a handle.
listConsumableApps / list_consumable_apps
Lists every non-private provider in the project — the discovery primitive for apps that hold the wildcard consume grant (consumes: [{ app: "*" }] in your data-template.yml, granted by the project user). It performs a single call and opens no connections: render the returned catalogs in a picker, then call connectToApp / connect_to_app for the ones you want — or connectToAllApps / connect_to_all_apps to open them all at once.
Note: Declare the grant in your app’s
data-template.yml, and quote the*— a bare*is a YAML alias and won’t parse:
consumes:
- app: "*"Python
providers = await ironflock.list_consumable_apps()
for p in providers:
print(p["app"], list(p["stages"].keys())) # e.g. "weather-app" ["dev", "prod"]Each entry describes one provider:
| Field | Type | Description |
|---|---|---|
app | str / string | Provider app name |
provider_app_key | int / number | The provider’s app key |
stages | dict / object | Per-stage catalog { dev?, prod? }; a stage is present only if the provider has a data backend for it. Each catalog holds the non-private tables and transforms it shares |
In Go each entry is an ironflock.ConsumedAppInfo with App, ProviderAppKey and Stages.Dev / Stages.Prod (a *StageCatalog with Tables and Transforms, nil for a missing stage); Catalog(stage) returns one by name.
Raises (Python) / throws (JavaScript) / returns (Go) a CrossAppAccessError with code: NO_GRANT if your app holds no wildcard grant.
connectToAllApps / connect_to_all_apps
Opens read-only handles to every non-private provider in the project in one call (wildcard consumers only). Enumerates providers via listConsumableApps / list_consumable_apps and opens each one, skipping any without a data backend for the requested stage. Each handle is cached under the same key as connectToApp / connect_to_app, so a later connectToApp(name) returns the already-warmed handle. The returned handles are closed together when your instance stops.
Python
apps = await ironflock.connect_to_all_apps(
on_error=lambda err: print("Provider skipped:", err)
)
for app in apps:
rows = await app.get_history(app.tables[0]["tablename"], {"limit": 10})
print(app.app, rows)Parameters:
| Parameter | Type | Description |
|---|---|---|
stage | str / string, optional | Provider stage: "dev" or "prod". Defaults to your app’s own stage |
continue_on_error / continueOnError | bool / boolean, optional | When true (the default), a provider that fails to open is reported to on_error / onError and omitted from the result. When false, the first failure is raised/thrown |
on_error / onError | callable, optional | Called with each provider that could not be opened (while continue_on_error / continueOnError is true), and with a CrossAppAccessError if an already-opened connection is later denied for good (e.g. the grant is revoked) |
Returns the successfully-opened provider handles (same handle type as connectToApp / connect_to_app). Raises (Python) / throws (JavaScript) / returns (Go) a CrossAppAccessError with code: NO_GRANT if your app holds no wildcard grant.
In Python,
stage,on_errorandcontinue_on_errorare keyword arguments; in JavaScript they are passed via an options object (connectToAllApps({ stage, onError, continueOnError })); in Go via a struct (ConnectToAllApps(ctx, ironflock.ConnectToAllAppsOptions{Stage, OnError, StopOnError})), whereOnErroris afunc(error)andStopOnError: truecorresponds tocontinueOnError: false.
Managed File Storage
Every app data backend gets private object storage alongside its tables, reached through the files property (ifl.Files() in Go, a *filestore.FileStore that may be fetched before Start). Use it for images, PDFs, camera frames, firmware blobs — anything that does not belong in a table row. No setup is required: an app with no files: section in its data template still gets one namespace called default (a template that declares namespaces of its own replaces it — see Namespaces).
This section is the client-side reference. For the platform side — declaring namespaces, quotas and retention, sharing files between apps, per-app S3 credentials and on-premises behaviour — see File Storage.
The key idea is that storing an object hands you back a permanent URL you can write straight into a table column, so a dashboard widget can render it with no further work:
Python
from datetime import datetime, timezone
# Store an object and get a permanent URL back in the same call
info = await ironflock.files.put("part-1.jpg", jpeg_bytes, content_type="image/jpeg")
# The URL is safe to store in a table column — a dashboard widget can then
# render <img src="{{photo_url}}"> without any extra round trip
await ironflock.publish_to_table("inspections", {
"tsp": datetime.now(timezone.utc).isoformat(),
"part_id": "1",
"photo_url": info.url,
})
# Read it back
data = await ironflock.files.get("part-1.jpg")
# Every object in the folder 2026/, in its subfolders too (pages are fetched for you)
async for obj in ironflock.files.iter(prefix="2026/"):
print(obj.key, obj.size)That URL never expires, but it is not a public link: it stays readable only to an authenticated requestor holding READ access on this data backend, and an auth proxy re-checks that on every request. It is therefore safe to store in the database.
Namespaces
A namespace is a key prefix that carries policy — retention, sharing rules, allowed content types. It is not a separate bucket; every namespace of an app lives inside that app’s single storage area. Declare one only when a set of objects needs different rules; otherwise stay in default and organise your objects with key paths like 2026/03/part-1.jpg.
Declare namespaces in data-template.yml. Declaring any replaces the implicit default: list one named default as well, or every call without a namespace fails with NO_SUCH_NAMESPACE.
files:
# Storage budget the app suggests for itself. The project user can change it,
# and their setting is the one that gets enforced.
quotaBytes: 5368709120
namespaces:
# Keep default: declaring namespaces replaces the implicit one.
- name: default
- name: frames
description: Raw camera frames, one JPEG per inspected part.
contentTypes: ["image/jpeg"]
maxObjectBytes: 20971520
retention: { deleteAfter: 30 days }Note that the budget is declared once for the whole app, not per namespace. A namespace is only a key prefix inside the app’s single storage area, so there is nothing for a per-prefix budget to be enforced against. maxObjectBytes is per namespace — it caps a single object, not a total: 100 MiB unless set, at most 5 GiB (OBJECT_TOO_LARGE above it).
Every method below takes the namespace as an optional argument and defaults to default; an empty namespace ("") means default too.
Storing and reading objects
Python
# Bytes in, bytes out
info = await ironflock.files.put("reports/march.pdf", pdf_bytes, content_type="application/pdf")
data = await ironflock.files.get("reports/march.pdf")
# Or straight from/to a local file — these stream on the large-object path,
# so a multi-gigabyte file never has to fit in memory
await ironflock.files.put_file("firmware/v2.bin", "/data/build/v2.bin")
await ironflock.files.get_to_file("firmware/v2.bin", "/tmp/v2.bin")| Method | Description |
|---|---|
put(key, data, …) | Stores an object (bytes in Python, Uint8Array in JavaScript, []byte in Go). Returns the object’s metadata including its url |
get(key, namespace?) | Returns the object’s content. An object over the inline limit comes directly from object storage, or in ranges through the router where that is not possible (see Large objects) |
put_file(key, path, …) / get_to_file(key, path, …) | Python and Go (PutFile / GetToFile; Go also streams from an io.Reader with PutReader and to an io.Writer with GetTo). Store from, or write to, a local file. Streams on the large-object path |
delete(key, namespace?) | Deletes an object |
copy(key, to, …) | Copies an object, optionally into another namespace |
move(key, to, …) | Copy-then-delete. Not atomic — the service has no move verb, so a failed delete leaves both copies |
JavaScript has no file-path helpers because the package ships a single build for both Node and the browser — read and write local files yourself with fs.
In Python, the file work of put_file and get_to_file runs off the event loop. get_to_file creates missing parent directories and writes the file in place once the object’s first bytes are in hand: a refused read (no such object, not authorized) leaves the path untouched, while a read that fails part-way leaves what was written until then. A FIFO is written as the object streams in, and opening it waits for a reader, so a FIFO nobody reads blocks the call (not the event loop); asyncio.wait_for can give it up. put_file reads a FIFO to its end before storing it.
In Go, GetToFile also creates missing parent directories, but downloads into a temporary file next to the target and renames it into place once complete: a failed or cancelled download never leaves a partial file and keeps the previous one. Symbolic links are followed (the file they lead to is written, and the links stay); the replacement gets, as far as the process may, the old file’s owner, group and permission bits. A FIFO or a device is written in place as the object streams in. For a target that cannot take a new file next to it, such as one on a read-only or pseudo file system, use GetTo with a file you open yourself.
put accepts: content_type / contentType (the MIME type; the namespace may restrict which are allowed) and namespace. In Python these are keyword arguments; in JavaScript they go in an options object; in Go they are options (filestore.ContentType(…), filestore.Namespace(…)).
Listing and inspecting
| Method | Description |
|---|---|
list(…) | One page of the file service’s listing, folder-style by default: objects holds the objects whose keys continue the prefix without a /, prefixes the folders below. A delimiter of "" lists flat — every object under the prefix, in subfolders too. Returns objects, prefixes, is_truncated / isTruncated / IsTruncated and a cursor to pass back for the next page |
iter(…) / iterate(…) | Async iterator over every object under a prefix, in subfolders too, paginating automatically. Named iter in Python, iterate in JavaScript, Iter in Go (an iter.Seq2[*ObjectInfo, error] for a range loop) |
stat(key, namespace?) | Metadata for one object without transferring its content |
exists(key, namespace?) | Whether an object exists |
namespaces() | The namespaces this app may use |
usage(…) | How much storage the app is using — see below |
catalog() | Namespaces plus server-issued limits and quotas. Fetched once and cached: catalog(refresh=True) / catalog(true) / RefreshCatalog(ctx) fetches it again |
Folders. A prefix that ends in / names a folder: iter(prefix="2026/") / iterate({ prefix: "2026/" }) / Iter(ctx, filestore.Prefix("2026/")) yields 2026/a.txt and 2026/sub/c.txt, but not 2026-notes.txt. list cannot open a folder by name, because the file service refuses a listing prefix that ends in / (FleetFiles v0.2 answers INTERNAL): list with the prefix 2026 returns 2026-notes.txt among the objects and the folder 2026/ among the prefixes. The service stores keys in Unicode normalization form C, so give a folder prefix in NFC too; an iteration that meets a key under another form of the prefix fails rather than skipping every key.
Objects are described by the same fields in all three SDKs, in each language’s naming style: namespace, key, size, etag, content_type / contentType, last_modified / lastModified, checksum_sha256 / checksumSha256 and url — in Go the fields Namespace, Key, Size, ETag, ContentType, LastModified, ChecksumSHA256 and URL of filestore.ObjectInfo.
Storage usage and quota
usage answers from the object store in a single call, so the totals are exact rather than added up by the SDK:
Python
u = await ironflock.files.usage()
print(u.size_bytes, u.object_count, u.quota_bytes, u.free_bytes)
# Break the total down per namespace (costs one listing per namespace)
detailed = await ironflock.files.usage(detail=True)
print(detailed.per_namespace) # {"default": 1048576, "frames": 73400320}In Go the fields are SizeBytes, ObjectCount, QuotaBytes, FreeBytes and PerNamespace of filestore.StorageUsage.
| Field | Meaning |
|---|---|
size_bytes / sizeBytes | Bytes currently stored |
object_count / objectCount | Number of objects stored |
quota_bytes / quotaBytes | The enforced budget. 0 means unlimited |
free_bytes / freeBytes | Bytes remaining. -1 means unlimited — reporting 0 there would read as “full” |
per_namespace / perNamespace | Bytes per namespace. Present only when you ask for the detailed breakdown |
The per-namespace breakdown is off by default because the store cannot answer it directly: it accounts per storage area, and a namespace is only a prefix, so the SDK has to list each namespace to add the sizes up. Ask for it when you want it, not on a hot path.
Two different quotas show up, and it is worth keeping them apart. catalog() reports both:
| Field | Meaning |
|---|---|
quota_bytes / quotaBytes | What is actually enforced, read from the object store — the project user’s setting |
suggested_quota_bytes / suggestedQuotaBytes | What the app’s data template asked for. 0 if it asked for nothing |
In Go, Catalog(ctx) returns a *filestore.Catalog with QuotaBytes and SuggestedQuotaBytes.
They differ whenever a user has raised or lowered the app’s budget, which is why the enforced value is read from the store rather than from the template — redeploying the app must not silently reset a user’s choice. A UI can show both (“the app suggests X, you have set Y”). Enforcement always uses the first one. The user can change the budget at any time, and catalog() reports it as of its last fetch: read the current value from usage().
Sharing objects
There are two kinds of link, and the difference matters:
| Method | Lifetime | Who can read it |
|---|---|---|
url(key, …) | Permanent | Only an authenticated requestor with READ on this data backend — re-checked on every request. Safe to store in a table column |
share_url / shareUrl / ShareURL | Expiring (default 15 min, server-clamped) | Anyone holding the link. Nothing re-checks authorization when it is used |
share_url / shareUrl is a bearer capability: hand it to a person who needs temporary access, and do not store it in the database. Use url for anything a dashboard renders.
url returns None / undefined / "" (Go) where the deployment has no HTTP edge (for example a plain-HTTP appliance) — that is the signal to fall back to get. Passing an object’s etag as the version argument (filestore.Version(etag) in Go) lets browsers cache the response immutably.
In Python and Go, cloud_url(key, …) / CloudURL(ctx, key, …) returns the permanent URL through an appliance’s cloud files tunnel (https://i<instanceKey>-files.<cloud-edge>): reachable from the internet while the appliance is online, with the same authorization as url. It returns None ("" in Go) on cloud-hosted deployments (where url is reachable from the internet already), where there is no HTTP edge, and while the data backend’s cloud share is switched off. The user can switch it at any time, so cloud_url / CloudURL re-reads the catalog when the cached one is older than 30 seconds.
upload_url / uploadUrl / UploadURL mints an expiring URL that accepts a direct upload, returning url, method, headers and expires_in / expiresIn (in Go a *filestore.UploadTarget with URL, Method, Headers and ExpiresIn). Send the headers as given, so the object is stored with the minted content type. The namespace’s content types and size cap, a declared size and the quota are checked when the URL is minted, not when it is used: the URL is signed for the host only, so anyone holding it can write any content to its key until it expires. Like share_url / shareUrl, it is a bearer capability — one that writes.
Large objects
The SDK chooses the transport by size, automatically — there is nothing to configure:
| Object size | How it travels |
|---|---|
| Up to the inline limit (currently 6 MiB) | A single call through the message router |
| Larger | Directly to object storage over HTTPS, bypassing the router |
| Larger, read where there is no direct path | In ranges through the message router |
The exact limit is reported by the server at runtime as inline_max_bytes / inlineMaxBytes / InlineMaxBytes in catalog(), so it can be raised without an SDK release.
The direct path needs a deployment that can presign URLs and a device that can reach the object storage host, not just the router. Where either is missing — the catalog says the deployment cannot presign (an on-premises appliance, by default), minting the read URL answers NOT_SUPPORTED, or object storage cannot be reached before any byte arrived — get (and Python’s get_to_file, Go’s GetTo and GetToFile) read the object in ranges through the router instead (FleetFiles v0.1.33 and later), each at most the inline limit. Each range is a read of its own: an object replaced during such a read fails it with INTERNAL (“object changed while it was read”). Writes have no such fallback.
Two ceilings remain for writes, and both report TOO_LARGE with a reason naming which one you hit:
- 5 GiB — the single-upload limit of the object store. Multipart upload is not implemented yet.
- The inline limit, where there is no direct endpoint — a deployment that cannot presign (an appliance, by default) cannot store a larger object at all. No retry or smaller chunk will help, and the message says so.
Two failures of the direct path common in the field get their own codes instead of looking like authorization problems: PRESIGN_UNREACHABLE (a proxy that allows only the router — a read falls back to ranges, a write fails) and CLOCK_SKEW (the object store rejects requests more than 15 minutes out of step — check NTP on the device).
File storage errors
A failure of the file service or of a direct transfer raises (Python) / throws (JavaScript) / returns (Go) a FileStoreError carrying a stable code and a human-readable reason. Branch on code, never on reason. Local file errors, a cancelled call and router-level WAMP errors other than the ones mapped below are raised as they are.
Python
from ironflock.filestore import FileStoreError
try:
await ironflock.files.put("huge.bin", payload)
except FileStoreError as e:
if e.code == "QUOTA_EXCEEDED":
print("Filestore is full:", e.reason)
else:
raise| Code | Meaning |
|---|---|
NOT_AUTHORIZED | The caller may not perform this operation |
NO_SUCH_NAMESPACE | The namespace is not declared in the data template |
NO_SUCH_OBJECT | The key does not exist |
TOO_LARGE | Exceeds the single-call transfer limit |
OBJECT_TOO_LARGE | Exceeds the namespace’s own maxObjectBytes |
QUOTA_EXCEEDED | The filestore is full |
CONTENT_TYPE_NOT_ALLOWED | The namespace restricts contentTypes |
INVALID_KEY | A malformed key or prefix (see below; FleetFiles v0.2 still reports it as INTERNAL) |
INVALID_RANGE | A byte range outside the object; the SDKs never ask for one |
NOT_SUPPORTED | The backend cannot do this |
NOT_AVAILABLE | This deployment has no file service — or not yet: after a platform restart, or while the file service registers its procedures. The call ran nowhere and may be repeated |
PRESIGN_UNREACHABLE | Object storage is not reachable directly (a proxy?) |
CLOCK_SKEW | The device clock is too far out of step |
INTERNAL | Anything else — also an object that changed during a ranged read |
A newer server may introduce codes this SDK release does not know. They are passed through as code rather than collapsed, so treat an unrecognised value as a generic failure.
File calls wait for the connection (up to 10 seconds in Python and Go, 6 in JavaScript) and are not retried: unlike table operations they do not ride out a platform restart, so during one a call can fail with NOT_AVAILABLE until the file service has registered again. Such a call ran nowhere, so your app may repeat it.
Keys. The file service is the authority on keys; the SDKs send keys and prefixes as given. A malformed key or prefix — empty; with a leading or trailing / (a folder prefix of iter / iterate aside); with an empty, . or .. segment, or a segment ending in . or starting or ending with a space; with one of \ : * ? " < > | ^ ~, a control or a Unicode format character; over 900 bytes, 20 segments or 255 bytes per segment; or under the reserved _sys/ prefix — is refused with INVALID_KEY by a file service that classifies key errors, and with INTERNAL by FleetFiles v0.2. Mind timestamps: 2026-10-09T12:00:00Z is not a valid key segment, because of the :.
In Python,
FileStoreErroris imported fromironflock.filestore; in JavaScript it is exported from the package root (import { FileStoreError } from "ironflock"); in Go it is*filestore.Error(alsoironflock.FileStoreError), with the codes as constants such asfilestore.CodeQuotaExceeded.
Cross-Device Communication
registerDeviceFunction / register_device_function
Registers a procedure that other devices running the same app (in the same stage) can call, and so can dashboard widget actions. The SDK builds the full URI <SWARM_KEY>.<DEVICE_KEY>.<APP_KEY>.<STAGE>.<topic> for you, where STAGE is DEV or PROD. The router accepts no other registration from an app, and single registrations only (see URIs an App May Use). The registration is restored after every reconnect.
Python
def add(a, b):
return a + b
await ironflock.register_device_function("com.myapp.add", add)
register()is an alias forregister_device_function().
callDeviceFunction / call_device_function
Calls a procedure registered by another device of the same app. The SDK assembles the full WAMP topic automatically using the target device’s key: <SWARM_KEY>.<device_key>.<APP_KEY>.<STAGE>.<topic>, the same URI registerDeviceFunction / register_device_function registers on the target device.
Python
result = await ironflock.call_device_function(
42, # target device key
"com.myapp.add", # procedure name
args=[3, 5] # arguments
)
print(result) # 8call
Calls a remote procedure using a full WAMP URI. An app cannot register a name like some.full.wamp.topic, so such a name only reaches procedures that platform services register on the app’s data realm, such as the data backend’s and the file storage’s. To call a function another device of your app registered, use callDeviceFunction / call_device_function.
Python
result = await ironflock.call("some.full.wamp.topic", args=[42])URIs an App May Use
Your app container joins its own data realm, realm-<SWARM_KEY>-<APP_KEY>-<dev|prod>, with the router role app. The router checks every publish, subscribe, call and register there against that role’s allow-list. The SDK’s own methods stay inside it; the rules matter when you pass your own URIs to publish, subscribe or call, or use the connection directly (Connection() in Go). A refused action fails with wamp.error.not_authorized (in Go, ironflock.WampURI(err) returns it).
| URI on your own realm | Allowed | Used by |
|---|---|---|
Your own names, e.g. com.myapp.status | publish, subscribe (prefix subscriptions too), call — never register | publish, subscribe, call |
<SWARM_KEY>.<APP_KEY>.<table>, bulk.<SWARM_KEY>.<APP_KEY>.<table> | publish — never subscribe | publishToTable / publish_to_table, reportError / report_error, publishRowsToTable / publish_rows_to_table |
transformed.<table>, transformed.bulk.<table> | subscribe only | subscribeToTable / subscribe_to_table |
databackend.errors.…, files.events… | subscribe only | The data backend’s error and file events |
<SWARM_KEY>.<DEVICE_KEY>.<APP_KEY>.<STAGE>.<name> | register (single), call, publish — never subscribe | registerDeviceFunction / register_device_function, callDeviceFunction / call_device_function |
append.…, appendBulk.…, history.transformed.…, secret.reveal.…, secret.verify.…, files.read.…, files.write.… | call (the SDK calls them) | Table, history, secret and file methods |
sys.appaccess.list, sys.appaccess.resolve | call | listConsumableApps / list_consumable_apps, connectToApp / connect_to_app, connectToAllApps / connect_to_all_apps |
wamp.session.get | call | The JavaScript SDK’s heartbeat |
Identity-checked names. Names that start with a digit — the table write topics and the device-function URIs — also pass the platform’s identity check, which binds them to your realm’s swarm key. A registration is accepted only for a function URI of your own device with your realm’s swarm key, app key and stage; a prefix or wildcard registration only when it fixes those four segments. Publish and call need only your realm’s swarm key: a row published to a table topic with another app key is accepted, but reaches no data backend and is lost. Let the SDK build these URIs.
Reserved names. The router refuses sys. apart from the two cross-app procedures above, and wamp. apart from wamp.session.get. The other names in the table belong to the platform: do not start your own names with a digit, bulk., transformed., databackend.errors., files., append., appendBulk., history. or secret.. Every other first segment is your app’s own topic space.
Nobody subscribes to raw table topics. They carry the row exactly as sent, including the plaintext of secret columns, which the data backend encrypts only when it stores the row. subscribeToTable / subscribe_to_table reads the stored rows from transformed.<table> instead, typed and with secret columns masked.
Other apps’ data is read-only. On a provider’s realm your app (router role app_reader) may only subscribe to transformed.… and files.events… and call history.transformed.… and files.read.…, besides the heartbeat wamp.session.get — no publish, no register, no writes and no secret procedures.
Device Metadata
setDeviceLocation / set_device_location
Asks the platform to update the device’s GPS location, which IronFlock maps show in real time.
Not available at the moment: no platform service answers this call on the app’s data realm yet, so it fails with
wamp.error.no_such_procedure. Keep locations in a table of your own until it is.
Python
await ironflock.set_device_location(long=8.6821, lat=50.1109)| Parameter | Range |
|---|---|
long | -180 to 180 |
lat | -90 to 90 |
Location history is not stored. To track location over time, create a dedicated table and use
publish_to_table/publishToTable.
getRemoteAccessUrlForPort
Returns the URL under which a port declared in the app’s port-template.yml is reachable once its tunnel is active.
Python
url = ironflock.getRemoteAccessUrlForPort(8080)
# "https://<device_key>-<app_name>-8080.app.ironflock.com"
vnc = ironflock.getRemoteAccessUrlForPort(5900, protocol="tcp")
# "tcp://app.ironflock.com:<the tunnel's remote port>"| Parameter | Type | Description |
|---|---|---|
port | int / number | The port, as declared in port-template.yml |
protocol | str / string, optional | The port’s protocol: "http" (default), "https", "tcp" or "udp", in any case; surrounding spaces are ignored, and an empty one is "http" |
remote_port_environment / remotePortEnvironment | str / string, optional | tcp/udp only: the variable that holds the remote port, instead of REMOTE_PORT_FOR_<port> — the port’s own remote_port_environment from the template, which device agents before 0.19.8 need |
http and https ports are served under the tunnel label <device_key>-<app name, lower case>-<port> (https ports with the platform’s secure- prefix) on the tunnel domain the device agent injects (TUNNEL_DOMAIN), always as an https:// URL. On a device that belongs to an on-premises appliance (the agent injects INSTANCE_KEY), the cloud-forwarded route https://i<INSTANCE_KEY>-<label>.<CLOUD_TUNNEL_DOMAIN> is returned instead.
A tcp or udp URL carries the public port the tunnel assigned, which the device agent announces as REMOTE_PORT_FOR_<port> (agent 0.19.8 and later): <protocol>://<TUNNEL_DOMAIN>:<remote port>. On an appliance’s device the internet-facing port, REMOTE_PORT_FOR_<port>_CLOUD, is preferred while the agent announces it; before it appears, and again once the agent has removed it (cloud forwarding turned off), the appliance-local URL is returned. A port of 0 has not been assigned yet.
The values are read live from /data/env, so call the method again rather than caching the result. Some cases the URL cannot serve:
- The device agent tunnels the ports of production (PROD) installs only. In a development (DEV) container the http(s) URL is that of the PROD install of the same app on this device, and tcp/udp give none; reach a DEV app on the device’s LAN address instead (
DEVICE_LAN_IP,DEVICE_PORT_FOR_<port>). - On an edge device of an appliance installed without a domain (plain mode), the agent sets
TUNNEL_DOMAINtolocalhost, so the URLs composed there point at localhost: such an appliance serves app ports on its host ports, which a tunnel URL cannot express. - Unset or empty tunnel domains default to
app.ironflock.com. The platform does not setCLOUD_TUNNEL_DOMAINtoday: an appliance’s URLs useapp.ironflock.comunless the app sets that variable itself, in its environment settings. - In a container that runs as a user other than root, the values are the ones the container started with (see Environment Variables).
The method returns None / null (in Go "" and false) when the URL cannot be composed: the device key or app name is missing, a tcp/udp port has no remote port announced yet (or 0), the protocol is unknown, or the label is not a valid DNS label (over 63 characters, or an app name with a dot). In a browser there is no environment: the http(s) URL is composed on app.ironflock.com from the deviceKey and appName options.
Connection Properties & Lifecycle
Python
| Property | Type | Description |
|---|---|---|
is_connected | bool | Whether the platform connection is active |
connection | CrossbarConnection | The underlying connection instance (advanced use) |
| Method | Description |
|---|---|
run() | Starts the connection and runs mainFunc (blocking) |
await start() | Starts the connection asynchronously |
await stop() | Stops the connection and the consumed-app connections, and cancels running tasks. The connections leave their realms and do not reconnect afterwards; start() connects anew. A start() or connect_to_app still waiting fails (see Connection Reliability) |
await run_async() | Starts and keeps the connection running asynchronously |
Connection Reliability
The SDK reconnects on its own. When the socket drops, it retries until the router is back, then restores every subscription and every registered device function — you do not need to re-subscribe after a reconnect. If one cannot be restored, the rest still are, and the failed one is retried while the session lasts — after 1 second, doubling up to 30 seconds, each wait varied by up to 15 % — and again after every reconnect: the router accepted it before, so its refusal is a passing one, such as the platform’s identity check of a device function failing closed for a moment. The first failure is logged as a warning, and the restore is logged once it succeeds.
Table operations — writes, history, series and secret reads, and a consumed app’s history reads — also ride out a platform restart, such as a router or platform update. They wait up to reconnect_window (Python, in seconds) / reconnectWindowMs (JavaScript) / WithReconnectWindow (Go, a time.Duration), 60 seconds by default, for the connection to come back, and retry while the data backend does not serve its procedures yet: the router answers wamp.error.no_such_procedure, or the data backend refuses a call that overtook its registration with “client has no handler for registration”. Such a call never reached a handler, so a retry never writes a row twice; any other error is raised as it comes. File calls are not retried (see File storage errors). A request already sent when its session closes (the router closes it, the link drops, or stop()) fails at once instead of waiting for an answer, and is not retried: it may have reached its handler. In Go, such a request — and any operation that finds no connection within its wait — fails with an error wrapping wamp.ErrNotConnected (wamp.ErrStopped after Stop). Go’s other operations (Publish, Subscribe, Call, the device functions, cross-app discovery) wait up to 10 seconds for a connection and are not retried.
A special case is a router that is reachable but has no realm for the app yet. The concurrent-install race boots the app container before the data backend has provisioned its realm, so the first joins are refused with wamp.error.no_such_realm; the SDK keeps retrying every couple of seconds and the app comes up the moment the realm does. A realm still missing after a minute is most likely never going to appear — the app was deleted, or has no data backend for this stage — so from then on the SDK slows to one attempt every two minutes rather than hitting the router every second forever. The fast cadence returns as soon as the realm appears, or the router itself goes away and comes back.
stop() ends every wait for a connection. A start() still waiting for its first join, for a realm that does not exist yet, say, fails with Connection to realm <realm> stopped before it opened. A connect_to_app / connectToApp still opening a provider’s connection fails with Connection to app '<app>' (<stage>) aborted by stop(); when the connection joins all the same, it is closed, and the call fails with Connection to app '<app>' (<stage>) closed by stop(). A denial that races stop() keeps its CrossAppAccessError. Nothing is left connecting when stop() returns; a router that does not answer the leave is cut off after 5 seconds.
In Go, Stop(ctx) ends these waits too, with errors wrapping wamp.ErrStopped: a Start still waiting returns it, and a ConnectToApp still opening fails with ironflock: connection to app '<app>' (<stage>) aborted by Stop (or closed by Stop, when the connection joined all the same). Stop waits for the leave within ctx and then closes the socket; Run gives it 10 seconds.
The harder case is a connection that dies silently: an idle NAT or proxy cuts it without telling either side, or a router restarts behind a load balancer. Nothing arrives to signal the loss. An app that only publishes finds out on its next failing write, but an app that only subscribes never writes at all, so without help it sits dead until the container restarts.
All three SDKs detect this. Detection is on by default and needs no configuration.
Python
The Python SDK uses WebSocket keepalive: it pings the router every 30 seconds while the link is idle and expects a pong within 10 seconds. If none arrives, the connection is dropped and reconnected. Incoming traffic counts as proof of life, so a busy connection is never probed — the ping only goes out when the link has actually gone quiet.
Cross-app connections opened with
connectToApp/connect_to_appare typically subscribe-only, which is exactly the case this protects. They are covered automatically, and they also reconnect after the router refuses them, until the refusal persists (seeconnectToApp).
Error Handling
Every SDK method fails loudly: on invalid arguments, a lost connection, or a rejection from the platform, it raises an exception (Python), rejects (JavaScript) or returns an error (Go) with a message naming the operation, the topic and the reason. Nothing is silently swallowed, so wrap calls you want to survive in a try block — in Go, check the returned error.
Python
try:
rows = await ironflock.getHistory("sensordata", {"limit": 100})
except ValueError as e:
# Invalid parameters — e.g. limit out of range, or a malformed filter
print(f"Bad query: {e}")
except RuntimeError as e:
# Not connected, table not in the data-template, or the platform rejected the call
print(f"Query failed: {e}")In JavaScript, failures coming from the platform are WampError instances — a normal Error subclass that additionally carries the WAMP error URI in error and the error payload in args / kwargs. Everything else (bad parameters, no connection) is a plain Error.
In Go, a failed operation returns an *ironflock.OperationError whose message names the operation and the cause, which it wraps; errors.Is and errors.As see through it. A refusal from the router or a callee is a *ironflock.WampError (*wamp.Error) with URI, Args and Kwargs; ironflock.WampURI(err) returns the URI, or "". Further sentinels are ironflock.ErrMissingConfig (identity the operation needs is missing), ironflock.ErrAlreadyStarted and wamp.ErrStopped (after Stop).
Upgrading: older Python and JavaScript SDK versions logged a message and returned
None/nullwhen a call failed. They now raise instead, so code shaped likeif result is None:no longer detects failures — usetry/except(ortry/catch).
Data backend refusals
An operation the data backend refuses fails with one of the WAMP error URIs below, the reason as its first argument. All three SDKs export them as constants; Go names them in Go style (ironflock.URIRateLimited, ironflock.URIStorageFull, …). In Python, the IronFlock methods raise a RuntimeError whose cause is autobahn’s ApplicationError (a consumed app’s reads raise the ApplicationError itself), and wamp_error_uri(err) reads the URI from either. In JavaScript, the error is a WampError whose error is the URI. In Go, it is an *ironflock.OperationError around a *ironflock.WampError, and ironflock.WampURI(err) reads the URI. The SDKs retry none of them. A published row the data backend refuses is dropped without an error — only the append methods report it.
Python
from datetime import datetime, timezone
from ironflock import URI_STORAGE_FULL, wamp_error_uri
row = {"tsp": datetime.now(timezone.utc).isoformat(), "temperature": 22.5}
try:
await ironflock.append_to_table("sensordata", row)
except RuntimeError as err:
if wamp_error_uri(err) == URI_STORAGE_FULL:
pending.append(row) # the appliance's disk is nearly full: append the row later
else:
raise| Constant | Refused |
|---|---|
URI_RATE_LIMITED | revealSecrets / reveal_secrets beyond 30, verifySecret / verify_secret beyond 120 calls a minute per app credential on a device |
URI_RESULT_TOO_LARGE | A read with a single row over the 8 MiB message budget, or a result over the 1 GiB guard (other large results arrive in chunks, which the SDK reassembles). Its keyword arguments name the largest columns: lower limit, narrow timeRange, select fewer columns, or prune with columnPaths |
URI_INVALID_LIMIT | A read of a transform with a limit over 3000 |
URI_INVALID_TIME_RANGE, URI_INVALID_METRIC, URI_INVALID_GROUP_BY, URI_SERIES_TOO_MANY_GROUPS | Series queries the table cannot answer: no start, or an end not after it; a metric the table cannot aggregate (no such column, a secret column, a method the column’s type does not take, tsp with a method other than COUNT); a groupBy column it cannot group by; more than 50,000 rows |
URI_SECRET_COLUMN, URI_NOT_A_SECRET_COLUMN | A filter on a secret column; verifySecret / verify_secret on a column that is not secret, or revealSecrets / reveal_secrets of a table without secret columns |
URI_STORAGE_FULL | An append while the appliance’s disk is nearly full (below about 3 GiB free; accepted again from 4 GiB) — temporary: append the rows later |
URI_STORAGE_OVERUSAGE | An append beyond the account’s storage allowance; retrying soon does not help |
URI_ENTITY_KEY_CONFLICT | A row whose entity key (maintainLatestFlagFor) and tsp another row already has, in the batch or stored: give every row its own tsp |
URI_SECRET_SENTINEL_UNRESOLVABLE, URI_SECRET_CIPHERTEXT_REJECTED | A write that keeps a secret’s previous value (__secret__) without an entity key to find it by; a write of an already encrypted value |
An append the data backend refuses without a reason — wamp.error.runtime_error, its answer to a row without tsp — is not a typed refusal: the SDK’s error message points at the missing tsp and at the app’s error-logs table, where the data backend records the cause.
Browser Usage (JavaScript only)
The JavaScript SDK works in modern browsers. Since browsers don’t have environment variables, pass all config via the constructor:
import { IronFlock } from "ironflock";
const ironflock = new IronFlock({
serialNumber: "device-serial-from-server",
deviceKey: "12",
appName: "MyWebApp",
swarmKey: 10,
appKey: 20,
env: "PROD",
});
await ironflock.start();
await ironflock.publishToTable("sensordata", [{ tsp: new Date().toISOString(), temperature: 22 }]);Use IronFlock.fromServer() to fetch configuration from your backend instead of hardcoding credentials:
const ironflock = await IronFlock.fromServer("/api/ironflock-config");
await ironflock.start();Your backend endpoint should return a JSON object with the connection options (serialNumber, deviceKey, appName, swarmKey, appKey, env).
Registering AI Agent Functions
The SDK can register functions that are callable by AI agents. Register a procedure and reference its topic in your ai-template.yml:
Python
def get_sensor_reading(sensor_id):
"""Returns the latest reading from a sensor."""
reading = read_from_hardware(sensor_id)
return {
"sensor_id": sensor_id,
"temperature": reading.temp,
"humidity": reading.hum,
"timestamp": reading.ts
}
await ironflock.register_device_function("sensors.get_latest", get_sensor_reading)The AI agent can then call this function when a user asks a question that requires live sensor data.
To wire the registered WAMP topic to an AI agent, reference it in your app’s .ironflock/ai-template.yml:
sensor_agent:
tool_description: |
Delegate to this agent when the user asks about sensor readings,
live device data, or current environmental conditions.
system_prompt: |
You are a sensor data specialist. Use get_current to retrieve
the latest reading from any sensor. Always include the unit in
your response.
main: true
max_context_tokens: 30000
max_iterations: 5
tools:
get_current:
description: Returns the latest reading from a sensor.
topic: sensors.get_latest
parameters:
sensor_id:
type: string
description: The sensor identifier to query.
required: trueThe topic value (sensors.get_latest) must match the name passed to register_device_function / registerDeviceFunction / RegisterDeviceFunction in your edge code. IronFlock automatically routes the call to the device where the function is registered.
For the full ai-template.yml reference, see Defining Agents & Tools.
Environment Variables
These variables are set automatically by the IronFlock runtime inside app containers:
| Variable | Description |
|---|---|
DEVICE_NAME | Device display name |
DEVICE_SERIAL_NUMBER | Unique, immutable device identifier |
DEVICE_KEY | Device key for authentication |
SWARM_KEY | Project identifier |
APP_KEY | App identifier |
APP_NAME | App name |
ENV | Environment: DEV or PROD |
APP_AUTH_ID, APP_AUTH_SECRET | The app’s own credential, which the SDK presents to the router. Cross-app access requires it; without it (an older device agent) the SDK falls back to the device serial number |
Python
import os
device_name = os.environ.get("DEVICE_NAME")
serial = os.environ.get("DEVICE_SERIAL_NUMBER")
project_key = os.environ.get("SWARM_KEY")The remote-access variables (TUNNEL_DOMAIN, DEVICE_LAN_IP, DEVICE_PORT_FOR_<port>, REMOTE_PORT_FOR_<port>, …) are listed under Configuring Ports.
The device agent also mirrors these values to /data/env, one <NAME>.txt file each, and keeps the mirror up to date while the app runs; the SDKs read it first, so values that change at runtime, such as a remote-access port, reach a running app. The per-app credential is read again for every connection attempt, so a credential the agent rotates while the app runs is presented on the next reconnect. The agent makes /data/env readable by root only: in a container that runs as another user, the SDK sees the values the container started with and logs a warning once.