Skip to Content
IoT App DevelopmentIronFlock SDK

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.

SDKPackageRequires
Pythonironflock on PyPIPython 3.11+
JavaScriptironflock on npmNode.js 22+ or modern browser
Gogithub.com/ironflock/sdk-goGo 1.27.2+

This page is the guide to the three SDKs. Each SDK’s README on GitHub is its complete reference: IronFlock/sdk-py, IronFlock/sdk-js and IronFlock/sdk-go.

Installation

pip install ironflock

Or add ironflock to your app’s requirements.txt.

Quick Start

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

ironflock = IronFlock( mainFunc=main, # async function to run after connecting serial_number="abc123", # override device serial (optional) reconnect_window=60, # seconds an operation rides out a platform restart (optional) )
ParameterDescription
mainFuncAn async function (async def) that runs once the connection is established. When it returns or raises, run() and run_async() stop the connection (see Connection Properties & Lifecycle)
ironFlockUrlThe router’s WebSocket URL, e.g. wss://cbw.ironflock.com/ws-ua-usr. Without it the SDK takes the URL from the environment (see Environment Variables)
serial_numberOverride 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_windowSeconds the table operations, publish, subscribe and register_device_function wait for the platform to come back after a restart before they raise. Defaults to 60; 0 turns it off (see Connection Reliability). IronFlock() raises ValueError for a negative value, NaN or an infinity, and TypeError for a value that is not a number

Publishing Data

publishToTable / publish_to_table

Writes 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.

It is the same as appendToTable / append_to_table: it calls the table’s append procedure, so it rides out a platform restart — it waits up to the reconnect window for the connection and retries while the data backend does not serve the table yet — and it raises (rejects, returns an error) when the data backend refuses the row. No row is lost while the platform restarts.

Version 1.9.1 and earlier of the three SDKs published the row to the table’s write topic instead: a row published while the data backend restarted was lost, and a refused row was dropped without an error. Now such a row fails the call — a row without tsp, say, or a table missing from the data template (after the reconnect window).

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 refuses a row without one, as described under appendToTable / append_to_table.

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 None is sent as null and clears the column. Remove the key from the dict to keep the old value.
  • JavaScript: a property whose value is undefined is dropped from the payload, so it keeps the old value. Set it to null to clear the column.
  • Go: a struct field tagged omitempty or omitzero is left out when it is empty, so it keeps the old value. Otherwise a nil pointer field, like a nil value in an ironflock.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: a refused row is an error. publishToTable / publish_to_table does the same; the only difference is the name the error message gives the operation.

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

Writes 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. It is the same as appendRowsToTable / append_rows_to_table: it calls the bulk append procedure, rides out a platform restart as publishToTable does, and fails when the data backend refuses the batch.

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 — fails the call, and no row of it is stored. (Version 1.9.1 and earlier of the SDKs published the batch to the table’s bulk topic, where a refused batch was dropped without an error.)

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. publishRowsToTable / publish_rows_to_table does the same. Every row needs its own tsp; the refusals are those of appendToTable, including the hint for a batch refused without a reason.

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.

# Writes a row to the error-logs table await ironflock.report_error("Sensor read timed out", level="warning") # Pass an exception to capture its traceback (falls back to the message) try: risky_operation() except Exception as err: await ironflock.report_error(err) # Show the operator a friendlier line than the raw error await ironflock.report_error( "Calibration failed: offset 0.42 out of range", 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:

ParameterTypeDescription
errorstr / string or exception / ErrorThe error message, or an exception whose traceback/stack (or message) is recorded
levelstr / string, optionalSeverity: "error", "warning", "info" or "debug", the types the board’s toast widget shows. Defaults to "error". "warn", the spelling of SDK 1.9.1 and earlier, is sent as "warning"
appendbool / boolean, optionalKept for compatibility: the row is appended either way, since publishToTable and appendToTable both append. It only picks which of the two the error message names
tspstr / string, optionalISO-8601 timestamp override. Defaults to the current time
user_message / userMessagestr / string, optionalOperator-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})), where errOrMsg is an error or a string, Level one of ironflock.LevelError, LevelWarning, LevelInfo, LevelDebug, and the returned *Result is the insert outcome.

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.

Like the table writes, publish adds the device metadata to the keyword arguments: DEVICE_SERIAL_NUMBER, DEVICE_KEY and DEVICE_NAME. A keyword argument of your own with one of these names wins.

Without a connection, publish waits up to the reconnect window for one (60 seconds by default, see Connection Reliability), so a publish rides out a short outage. It is then sent once, never retried. The router acknowledges a message nobody subscribes to, so a subscriber that is down meanwhile misses it.

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.

# 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:

FieldTypeDescription
limitint / numberMaximum rows to return (1–10,000, required; 1–3,000 for a transform)
offsetint / numberOffset for pagination
timeRangelist / 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
filterAndlist / arrayAND-ed filter entries: conditions, groups, and/or the latest marker (see below)
columnslist / arrayColumns to return (optional). tsp, device_key and authid are always included; omit for all columns
columnPathslist / arrayPrunes 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

When you pass query parameters, limit is required; without any (nil in Go), getHistory reads the 10 newest rows.

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. JavaScript exports this list as SQL_OPERATORS; in Go, ironflock.SQLOperators() returns it.

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":

# 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. The Python SDK also refuses a string that datetime.fromisoformat cannot read. 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.

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:

FieldTypeDescription
metricslist / arrayThe 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
bucketMsint / numberBucket width in whole milliseconds, at least 1000 (optional). Omit it to divide the time range into limit buckets
limitint / numberThe bucket budget (1–10,000) — not a row limit
timeRangelist / 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
groupBylist / arrayColumns that split every bucket into a row per group (optional)
filterAndlist / arrayAND-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 metrics as column names plus a top-level method ({"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 (a DeprecationWarning in Python, a console warning once per process in JavaScript), and renames the result columns back to the column names, so old code keeps reading row["temperature"] — unless the row already has a column of that name, such as tsp for COUNT of tsp. Move to {ref, method} metrics and read row["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.

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)

subscribeToTable / subscribe_to_table returns a TableSubscription, the handle of both feeds: its unsubscribe() removes them and ends the catch-up described below (see unsubscribe / unregister).

Rows stored while the connection was down arrive after the reconnect. The router does not replay what was published while the connection was down, so after every reconnect the SDK reads the rows stored meanwhile and delivers them to your handler, oldest first, before the rows that arrive live: every row whose tsp is later than the newest row your handler got. Before the first row, the start is the newest row stored when you subscribed: the SDK reads it right after subscribing, and when that read fails, it starts at the time of subscribing (by the device’s clock); a table that was empty then is read from its start. A row written meanwhile with an older tsp (a device sending readings it buffered, say) is not caught up. One catch-up delivers at most the newest 10,000 rows, and logs a warning when it read that many. The catch-up read waits for the data backend like any history read, up to the reconnect window, and the live rows wait for it; when it fails, the rows stored meanwhile are not delivered, and a warning is logged. Caught-up rows come from a read, not from events, so the handler gets each one without event details: in Python the row only, in JavaScript ([row], {}), in Go an Event with Topic and Args, delivered in turn on the subscription’s DeliveryGroup. The catch-up is part of the SDK from version 1.10.0.

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. A transform needs no catch-up: its next tick after a reconnect publishes the whole view again. 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.

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).

def on_alert(*args, **kwargs): print("Alert received:", args, kwargs) alerts = await ironflock.subscribe("com.myapp.alerts", on_alert) # Later: the handler is called for no further event await alerts.unsubscribe()

subscribe returns a Subscription, the handle of the subscription (see unsubscribe / unregister). Every call returns a handle of its own, also for a topic that is subscribed already.

Without a connection, subscribe waits up to the reconnect window for one (60 seconds by default), as publish does, and so does subscribeToTable / subscribe_to_table (see Connection Reliability).

The topic is matched exactly. Subscribe options, an optional argument after the handler, can change that: a match of "prefix" subscribes every topic that starts with the given one. In Python the options are autobahn’s SubscribeOptions (SubscribeOptions(match="prefix"), from autobahn.wamp.types), in JavaScript an object ({ match: "prefix" }), and in Go ironflock.SubscribeOptions{Match: "prefix"}. The subscription is restored with the same options after every reconnect. subscribeToTable / subscribe_to_table takes the same options and subscribes both of its feeds with them.

unsubscribe / unregister

subscribe, subscribeToTable / subscribe_to_table and registerDeviceFunction / register_device_function each return a handle. A handle stays valid across reconnects until you remove it: the SDK restores what it stands for after each reconnect.

HandleReturned byMembers
Subscriptionsubscribeunsubscribe(), topic, active
TableSubscriptionsubscribeToTable / subscribe_to_table, of your own app and of a consumed appunsubscribe(), table, active
RegistrationregisterDeviceFunction / register_device_function and its aliasesunregister(), procedure, active

topic, table and procedure name what the handle stands for; procedure is the full WAMP URI. active tells whether the subscription or registration is live at the router now: it is false while the connection is down, after a failed restore until a retry succeeds, and once the handle is removed. A TableSubscription is active while both of its feeds are. ironflock.unsubscribe(handle) takes a Subscription or a TableSubscription, and ironflock.unregister(registration) takes a Registration; each does the same as the handle’s own method.

status = await ironflock.subscribe("com.myapp.status", on_status) readings = await ironflock.subscribe_to_table("sensordata", on_row) add = await ironflock.register_device_function("com.myapp.add", add_numbers) print(readings.table, readings.active) # sensordata True await status.unsubscribe() # the same as: await ironflock.unsubscribe(status) await ironflock.unsubscribe(readings) # both feeds of the table, and its catch-up await ironflock.unregister(add) # the same as: await add.unregister()

unsubscribe() and unregister() act at once, when you call them, and return an awaitable: await it to know that the router has answered; a refusal is raised there. A handler that is a plain function, not async, can call subscription.unsubscribe() without awaiting it. The three handle classes are exported from ironflock.

A removal follows the same rules in all three SDKs:

  • It takes effect at once. The handler is called for no further event: not for one still queued, not for the remaining rows of a bulk insert, not for the rows a catch-up would deliver. A removed function is called no more; a call that the router still routes to it before it has dropped the registration is refused with wamp.error.no_such_procedure. A call already under way is not waited for, so a handler may remove its own subscription. What you remove is never restored after a reconnect.
  • It does not wait for a connection. While the connection is down, the subscription or registration is removed locally, because the router dropped it with the session, and the call returns at once. While the connection is up, the router is told, and the call completes once the router has answered. A refusal is raised (Python, a RuntimeError), thrown (JavaScript, a WampError) or returned (Go), but the removal stands all the same.
  • It is idempotent. A second call does nothing, and so does a call without a handle (None, null or undefined, nil). After stop() / Stop, a call only removes the subscription or registration locally: in Python and JavaScript, the next start() then does not restore it.
  • Handles are independent. Removing one handle never removes the handler of another, also on the same topic or table. The subscription at the router ends with the last handle of its topic.
  • A TableSubscription removes both of its feeds, transformed.<table> and transformed.bulk.<table>, and ends its catch-up.

Upgrading: before version 1.10.0, the Python and JavaScript SDKs returned autobahn’s subscription and registration objects, and subscribe_to_table / subscribeToTable returned only the subscription of the row feed, so removing it left the bulk feed subscribed. Code that calls unsubscribe() or unregister() on the returned value keeps working, and what it removes is no longer restored after a reconnect. Other members of autobahn’s objects, such as id, are gone.

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 pathWhat a secret column looks like
getHistory, subscribeToTable / subscribe_to_table and a consumed app’s reads (the history reads and realtime rows that boards use too)the placeholder __secret__, exported as SECRET_PLACEHOLDER (ironflock.SecretPlaceholder in Go)
SQL (the SQL query console, data exports and the FleetDB Access Postgres login) and database backupsthe stored ciphertext, ifsec:1:…
revealSecrets / reveal_secretsthe 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.

Only a string column can be secret, and neither tsp nor an entity key can be. The data backend encrypts each value with AES-256-GCM and a random IV of its own, so the same secret is stored as different ciphertext in every row: a secret column cannot be filtered, grouped or sorted. A filter on one is refused with URI_SECRET_COLUMN; to test a value, use verifySecret.

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.

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, except that 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. Without query parameters the call reads the 10 newest rows. Unlike getHistory, it returns the rows newest first.

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 filter on a secret column is refused with URI_SECRET_COLUMN. The data backend serves this function only for tables that declare a secret column: for any other table the router answers wamp.error.no_such_procedure, which the SDK retries for the reconnect window, and then the call fails with an error saying that the procedure is not registered. The error names the causes, the most likely first: the table declares no secret column (secret: true in the data template), the table is not declared, or the data backend predates secret columns.

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.

ok = await ironflock.verify_secret( "credentials", "api_token", submitted_token, {"limit": 1, "filterAnd": [{"column": "device_id", "operator": "=", "value": "d-1"}]}, ) if ok: ...

Parameters:

ParameterTypeDescription
tablenamestr / stringThe table to check against
columnstr / stringThe secret column to compare
candidatestr / stringThe plaintext being tested. Compared server-side and never written to the table
query_params / queryParamsSecretQueryParamsWhich 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; on a table without any secret column, the call fails as revealSecrets / reveal_secrets does there.

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 stage 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. Its subscribeToTable / subscribe_to_table returns the same TableSubscription handle as for your own tables, which ironflock.unsubscribe takes as well (see unsubscribe / unregister), and its table subscriptions catch up after a reconnect the same way (see subscribeToTable).

The handle also carries the provider’s name and stage (app and stage, in Go App and Stage), and its connection to the provider’s realm: is_connected / isConnected / IsConnected() tells whether it is open, connection / Connection() is the connection itself. In JavaScript, close() resolves once the connection has closed, within 5 seconds.

# 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:

ParameterTypeDescription
app_name / appNamestr / stringProvider app name, as declared in your consumes: section
stagestr / string, optionalProvider stage: "dev" or "prod". Defaults to your app’s own stage
on_error / onErrorcallable, optionalCalled 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 consumed app’s subscribeToTable / subscribe_to_table that the provider’s router refuses (wamp.error.not_authorized, wamp.error.authorization_failed or wamp.error.authentication_failed) fails with a CrossAppAccessError with NOT_AUTHORIZED as well, as a refused read does.

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, stage and on_error are 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})), where OnError is a func(*ironflock.CrossAppAccessError). In Go, ctx bounds only the caller’s wait: concurrent calls for the same app share one attempt, and Close(ctx) closes a handle. Go matches app names in any case, and ConsumedApp.Stage is the stage in lower case ("dev" or "prod"), unlike ifl.Stage().

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: "*"
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:

FieldTypeDescription
appstr / stringProvider app name
provider_app_keyint / numberThe provider’s app key
stagesdict / objectPer-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.

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:

ParameterTypeDescription
stagestr / string, optionalProvider stage: "dev" or "prod". Defaults to your app’s own stage
continue_on_error / continueOnErrorbool / boolean, optionalWhen 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 / onErrorcallable, optionalCalled with the error of 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_error and continue_on_error are 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})), where OnError is a func(error) and StopOnError: true corresponds to continueOnError: 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). The property does no I/O, so in all three SDKs it may be read before the connection is started. 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:

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 checks that on every request, caching its decision for up to 5 minutes. 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

# 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")
MethodDescription
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

put and get hold the whole object in memory. For a large object, use the file variants, which stream (in Go also PutReader and GetTo). 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

MethodDescription
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. A page holds up to 200 objects unless you pass another limit. 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:

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.

FieldMeaning
size_bytes / sizeBytesBytes currently stored
object_count / objectCountNumber of objects stored
quota_bytes / quotaBytesThe enforced budget. 0 means unlimited
free_bytes / freeBytesBytes remaining. -1 means unlimited — reporting 0 there would read as “full”
per_namespace / perNamespaceBytes 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:

FieldMeaning
quota_bytes / quotaBytesWhat is actually enforced, read from the object store — the project user’s setting
suggested_quota_bytes / suggestedQuotaBytesWhat 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:

MethodLifetimeWho can read it
url(key, …)PermanentOnly an authenticated requestor with READ on this data backend — checked on every request (a decision is cached for up to 5 minutes). Safe to store in a table column
share_url / shareUrl / ShareURLExpiring (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. In a namespace declared share: none the permanent URL does not work either, but url cannot tell, because the catalog does not report how a namespace is shared: it returns the URL all the same, and the file edge answers it with 404. Read such objects with get; share_url / shareUrl / ShareURL links do not depend on share. 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 (valid for 1 hour by default) 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 sizeHow it travels
Up to the inline limit (currently 6 MiB)A single call through the message router
LargerDirectly to object storage over HTTPS, bypassing the router
Larger, read where there is no direct pathIn 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.

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
CodeMeaning
NOT_AUTHORIZEDThe caller may not perform this operation
NO_SUCH_NAMESPACEThe namespace is not declared in the data template
NO_SUCH_OBJECTThe key does not exist
TOO_LARGEExceeds the single-call transfer limit
OBJECT_TOO_LARGEExceeds the namespace’s own maxObjectBytes
QUOTA_EXCEEDEDThe filestore is full
CONTENT_TYPE_NOT_ALLOWEDThe namespace restricts contentTypes
INVALID_KEYA malformed key or prefix (see below; FleetFiles v0.2 still reports it as INTERNAL)
INVALID_RANGEA byte range outside the object; the SDKs never ask for one
NOT_SUPPORTEDThe backend cannot do this
NOT_AVAILABLEThis 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_UNREACHABLEObject storage is not reachable directly (a proxy?)
CLOCK_SKEWThe device clock is too far out of step
INTERNALAnything 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 up to 10 seconds for the connection (issued before start() / Start, for it as well) 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, FileStoreError is imported from ironflock.filestore; in JavaScript it is exported from the package root (import { FileStoreError } from "ironflock"); in Go it is *filestore.Error (also ironflock.FileStoreError), with the codes as constants such as filestore.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 PROD when ENV is PROD (in any case) and DEV otherwise. Before it sends anything, the SDK refuses an empty topic and a device, project or app key that is missing or 0; so does callDeviceFunction / call_device_function. The router accepts no other registration from an app, and single registrations only (see URIs an App May Use). Without a connection, the method waits up to the reconnect window for one (60 seconds by default, see Connection Reliability). The registration is restored after every reconnect. The method returns a Registration, the handle of the registration: its unregister() removes it (see unsubscribe / unregister).

def add(a, b): return a + b await ironflock.register_device_function("com.myapp.add", add)

register() is an alias for register_device_function().

The former names register_function / registerFunction / RegisterFunction and call_function / callFunction / CallFunction still work, but are deprecated.

Apps that override ENV: SDK versions up to 1.8.6 (Python) and 1.8.4 (JavaScript) put the raw ENV value into the stage segment. That makes a difference only when an app sets ENV itself with another spelling, such as prod: devices on those versions and devices on newer ones then cannot call each other’s functions.

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.

result = await ironflock.call_device_function( 42, # target device key "com.myapp.add", # procedure name args=[3, 5] # arguments ) print(result) # 8

call

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.

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 Python the method raises a RuntimeError, and wamp_error_uri(err) returns the URI; in JavaScript the error is a WampError whose error is the URI; in Go, ironflock.WampURI(err) returns it.

URI on your own realmAllowedUsed by
Your own names, e.g. com.myapp.statuspublish, subscribe (prefix subscriptions too), call — never registerpublish, subscribe, call
<SWARM_KEY>.<APP_KEY>.<table>, bulk.<SWARM_KEY>.<APP_KEY>.<table>publish — never subscribe– (the table write topics; the SDK’s table writes append instead)
transformed.<table>, transformed.bulk.<table>subscribe onlysubscribeToTable / subscribe_to_table
databackend.errors.…, files.events…subscribe onlyThe data backend’s error and file events
<SWARM_KEY>.<DEVICE_KEY>.<APP_KEY>.<STAGE>.<name>register (single), call, publish — never subscriberegisterDeviceFunction / 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.resolvecalllistConsumableApps / list_consumable_apps, connectToApp / connect_to_app, connectToAllApps / connect_to_all_apps
wamp.session.getcallThe 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.

await ironflock.set_device_location(long=8.6821, lat=50.1109)
ParameterRange
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.

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>"
ParameterTypeDescription
portint / numberThe port, as declared in port-template.yml
protocolstr / string, optionalThe 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 / remotePortEnvironmentstr / string, optionaltcp/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 port 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_DOMAIN to localhost, 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 set CLOUD_TUNNEL_DOMAIN today: an appliance’s URLs use app.ironflock.com unless 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

PropertyTypeDescription
is_connectedboolWhether the platform connection is active
connectionCrossbarConnectionThe underlying connection instance (advanced use)
filesFileStoreThe app’s managed file storage (see Managed File Storage)
MethodDescription
run()Starts the connection and runs mainFunc (blocking), then stops the connection
await start()Starts the connection asynchronously. Operations issued before it wait for it, within their wait (see Connection Reliability)
await stop()Stops the connection and the consumed-app connections, and cancels the main task (mainFunc). The connections leave their realms and do not reconnect afterwards; start() connects anew. Every operation still waiting for a connection, or for start(), fails at once, and so does a start() or connect_to_app still waiting; an operation issued after stop() waits for the next start() (see Connection Reliability)
await run_async()Starts and keeps the connection running asynchronously

run() and run_async() stop the connection as soon as mainFunc returns or raises; an exception it raises is raised again after the stop. A mainFunc that only subscribes or registers device functions must therefore keep running, for example with await asyncio.Event().wait(). mainFunc must be an async function (async def). Without a mainFunc, run() and run_async() keep the connection open until the process ends.

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, and the handles you got stay valid: their active tells whether each one is live at the router now (see unsubscribe / unregister). Table subscriptions also deliver the rows stored while the connection was down (see subscribeToTable); messages on your own topics are not stored, so those published meanwhile are lost. 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 — the table writes, history, series and secret reads, and a consumed app’s history and series 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; one issued before start() / Start waits for it within the same window. They also 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. The catch-up read of a table subscription is one of these reads.

The table writes include publishToTable / publish_to_table, publishRowsToTable / publish_rows_to_table and reportError / report_error: they call the same append procedures as appendToTable and appendRowsToTable, so a row written while the data backend restarts is not lost.

publish, subscribe, subscribeToTable / subscribe_to_table (a consumed app’s too) and registerDeviceFunction / register_device_function also wait for a connection within the reconnect window, so a short outage does not make them fail. They are not retried; a subscription or registration is restored after every later reconnect.

Every other operation — call, callDeviceFunction / call_device_function, setDeviceLocation / set_device_location, the file calls, and the cross-app lookups and opens — waits up to 10 seconds for a connection and is not retried. One issued before start() / Start waits for it within those 10 seconds. An operation that gets no connection within its wait fails with an error that names the wait in seconds: in Python, the IronFlock methods raise a RuntimeError whose cause is a TimeoutError; in JavaScript, it is a plain Error; in Go, an error wrapping wamp.ErrNotConnected. unsubscribe and unregister do not wait at all: they take effect at once, also while the connection is down (see unsubscribe / unregister).

0 turns the window off: these operations then behave like every other operation. They wait 10 seconds for a connection and are not retried, so a catch-up right after a platform restart gets no retry either. A negative window, and in Python and JavaScript also NaN or an infinity, is refused when the IronFlock object is created (see Constructor Options).

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 Python, the error’s cause is autobahn’s ApplicationError with the close reason (e.g. wamp.close.system_shutdown), or TransportLost when the link dropped. In JavaScript, it is a WampError whose error is the close reason: the router’s, wamp.close.goodbye_and_out after stop(), or wamp.close.transport_lost when the link dropped. In Go, such a request fails with an error wrapping wamp.ErrNotConnected (wamp.ErrStopped after Stop).

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() / Stop ends every wait at once. An operation still waiting for a connection — or, issued before start() / Start, for it — fails with the SDK’s stopped error, and so does a table call that pauses between two retries: in Python a ConnectionError saying that the IronFlock connection was stopped (the IronFlock methods raise it as the cause of their RuntimeError), in JavaScript a ConnectionStoppedError, and in Go an error wrapping wamp.ErrStopped. 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; in Go, Start returns an error wrapping wamp.ErrStopped. 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(). In Go, ConnectToApp fails with ironflock: connection to app '<app>' (<stage>) aborted by Stop (or closed by Stop). A denial that races stop() keeps its CrossAppAccessError. In Python and JavaScript, nothing is left connecting when stop() returns, and a router that does not answer the leave is cut off after 5 seconds; Go’s Stop waits for the leave within ctx and then closes the socket, and Run gives it 10 seconds.

In Python and JavaScript, the instance can be started again after stop(): start() connects anew, and an operation issued after stop() waits for the next start(), as one issued before the first start() does. Go’s Stop is final: an operation issued after it fails at once with wamp.ErrStopped.

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.

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_app are 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 (see connectToApp).

Error Handling

An SDK method that fails — on invalid arguments, a lost connection, or a rejection from the platform — raises an exception (Python), rejects (JavaScript) or returns an error (Go) with a message naming the operation, the topic and the reason, so wrap calls you want to survive in a try block — in Go, check the returned error. Some failures do not reach your code. A message published to a topic of your own reaches no subscriber that is down meanwhile. A failed catch-up read after a reconnect is only logged (see subscribeToTable).

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, a refusal by the router or a callee is usually a WampError — a normal Error subclass that additionally carries the WAMP error URI in error and the error payload in args / kwargs. Three kinds of call fail differently: a history, series, reveal or verify call whose procedure is not registered fails with a plain Error that says so and names the likely causes, and only after the whole reconnect window, because wamp.error.no_such_procedure is retried; a file call fails with a FileStoreError (see File storage errors); and a cross-app call that is denied, a consumed app’s subscribeToTable included, fails with a CrossAppAccessError. An operation that stop() ends while it waits for the connection fails with a ConnectionStoppedError, an exported Error subclass. 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 (an operation after Stop, or one that Stop interrupted).

Upgrading: older Python and JavaScript SDK versions logged a message and returned None / null when a call failed. They now raise instead, so code shaped like if result is None: no longer detects failures — use try / except (or try / 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. Every table write reports them, the publish methods included: they append.

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
ConstantRefused
URI_RATE_LIMITEDrevealSecrets / reveal_secrets beyond 30, verifySecret / verify_secret beyond 120 calls a minute per app credential on a device
URI_RESULT_TOO_LARGEA 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_LIMITA read of a transform with a limit over 3000
URI_INVALID_TIME_RANGE, URI_INVALID_METRIC, URI_INVALID_GROUP_BY, URI_SERIES_TOO_MANY_GROUPSSeries 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_COLUMNA filter on a secret column; verifySecret / verify_secret on a column that is not secret. A table without secret columns has no reveal or verify function at all (see revealSecrets)
URI_STORAGE_FULLAn 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_OVERUSAGEAn append beyond the account’s storage allowance; retrying soon does not help
URI_ENTITY_KEY_CONFLICTA 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_REJECTEDA 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:

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: true

The 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:

VariableDescription
DEVICE_NAMEDevice display name
DEVICE_SERIAL_NUMBERUnique, immutable device identifier
DEVICE_KEYThe device’s numeric key. The SDK builds device-function URIs and remote-access tunnel labels from it and sends it as metadata with publications and table writes; it does not authenticate with it
SWARM_KEYProject identifier
APP_KEYApp identifier
APP_NAMEApp name
ENVEnvironment: DEV or PROD
APP_AUTH_ID, APP_AUTH_SECRETThe 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
DEVICE_ENDPOINT_URLThe router URL the SDK connects to, with its path replaced by /ws-ua-usr. It is preferred over RESWARM_URL; a router URL passed as an option (ironFlockUrl, WithURL in Go) overrides both
RESWARM_URLThe studio URL, from which the SDK derives the router URL when DEVICE_ENDPOINT_URL is not set. A studio URL the SDK does not know makes start() / Start fail. Without either variable, the SDK connects to wss://cbw.ironflock.com/ws-ua-usr
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 two kinds of values from it, file first, so that a change reaches a running app: the per-app credential (APP_AUTH_ID, APP_AUTH_SECRET), read again for every connection attempt, so a credential the agent rotates while the app runs is presented on the next reconnect; and the remote ports (REMOTE_PORT_FOR_<port> and REMOTE_PORT_FOR_<port>_CLOUD), read on every call of getRemoteAccessUrlForPort. All other values — the identity (DEVICE_KEY, DEVICE_NAME, …), ENV and the tunnel domains — come from the environment the container started with (Go reads the identity and ENV once, in New). Older device agents make /data/env readable by root only: in a container that runs as another user, the SDK then sees the values the container started with and logs a warning once. In JavaScript, reading the mirror takes Node.js 22.3 or later; an older Node.js sees only the environment.

Last updated on