Docs / Contact / devices/remote-deployments
Remote Deployments (OTA)
Remote deployment is delivered by an optional add-on, the device-side updater module, chosen when you provision a board. It's on by default for the Full tier, an opt-in checkbox for Minimal, and you can uncheck it on either (ROMFS installs always include it). A board provisioned without the add-on can't receive remote deployments: a deploy to it reports ota_not_installed; just re-provision with the box checked to enable them. See Provisioning → Choosing a tier.
What it is (and isn't)
Remote deployment pushes your application code (the Python your device runs) to devices in the field over Wi-Fi. It is not firmware OTA: it never touches the MicroPython interpreter or the Tendrl SDK, only the code in your /app/ directory. That keeps the update small, fast, and recoverable, and it works the same on every supported board (ESP32 family, Pico W, OpenMV) with no partition changes.
How your device is laid out
On a board that has the OTA add-on, the first deployment converts the boot file into a small, managed shim (and moves your code into /app/). From then on the boot file stays this shim:
# main.py, managed by Tendrl (do not edit). App code lives in /app/.
tendrl.updater.run()
Your application code lives in /app/ (on OpenMV, /flash/app/). The entry module named in a deployment runs on boot. A minimal app:
# /app/main.py
from tendrl import Client
client = Client(mode="sync")
client.start()
while True:
# your logic here
time.sleep(5)
The shim belongs to the SDK, not to your deployment, so a bad deployment can never overwrite the code that recovers from it.
The entry module
The entry is the module the device imports to start your app. It is resolved in this order:
- The deployment's Entry field, if you set one: a per-deployment override.
- Single-file default: a deployment containing exactly one top-level file
- The device's
app_entryconfig setting (inconfig.json): the device's main: the fallback, matching MicroPython's usualmain.pyconvention.
with a blank Entry automatically uses that file's name as the entry (deploy just app.py and app is the entry). Every file you deploy must be runnable-or-imported; there is no "extra file alongside the app" in a single-file deployment.
standing default. Set this once if your program isn't main.py (e.g. "app_entry": "app" runs /app/app.py).
For multi-file deployments, leave the Entry field blank to defer to the device's app_entry. Name the module without the .py (main, not main.py).
File paths in your app
Before importing your entry, the updater changes the working directory into /app/. So relative paths resolve against your app's own folder: a file you deployed as data.json is opened with open("data.json"), not open("/app/data.json"). (The SDK's own files, config.json and the offline queue, are addressed by absolute path and are unaffected.)
Assets: models and other files beside your code
A deployment can carry more than .py/.mpy. Asset files — a .tflite model, a model.json sidecar, a .bin blob — ride in the same bundle, land in /app/ next to the code, and go through the same staging, SHA-256 check, journal and rollback. Because the working directory is /app/, your code loads them by name:
MODEL = json.load(open("model.json")) # {"name": "glasses-v3", "labels": [...], ...}
clf = ml.Model("glasses.tflite") # /app/glasses.tflite
Limits (per deployment): up to 50 files and 4 MB in total; each code file up to 256 KB; each asset up to 2 MB. Anything that is neither code nor one of the three asset types is refused. Assets are malware-scanned like code (a .tflite is presented to the scanner as a binary). If the app itself is unchanged and only the model is new, ship the code again anyway — a bundle is the whole /app that should be live, and a redeploy is what makes the device re-import with the new model. See Custom vision models for the train → export → deploy → verify loop.
Rules your entry module must follow
The updater starts your app by importing the entry module, not by running it as a script. A few consequences are worth knowing up front:
- It survives power loss automatically, so you don't manage
main.py./main.py - Don't gate your code behind
if __name__ == "__main__":. Because the entry - Keep it running. Start the client and then stay alive, with a
while True:loop, - Never edit
/main.py. It's the managed shim, owned by the SDK. Your code
stays a tiny Tendrl-managed shim; on every boot it runs the updater, which imports your entry from /app/. You never need a boot.py, and your entry file never needs to be named main.py (name it anything and set it as the entry).
is imported, __name__ is the module's name (e.g. "app"), not "__main__", so code inside that block never runs. Put your startup at the top level, or call it unconditionally.
or rely on client.start()'s background timer. During the validation boot, an entry that returns before the client has connected causes an automatic rollback (entry_returned_without_commit): the deployment only commits once the device reaches the server. After a committed deploy, an entry that returns simply leaves the device idle until its next boot.
lives only in /app/; supporting modules you deploy alongside the entry are importable because /app/ is on the path.
A correct entry module:
# /app/app.py, deployed as the entry "app" (typed in Entry, or via app_entry)
from tendrl import Client
client = Client(mode="sync")
client.start() # connects; keeps a background timer alive
@client.tether(tags=["sensors"])
def read_sensors():
return {"temperature": 22.5}
while True: # stay alive so the device stays connected
read_sensors()
time.sleep(10)
Deploying from the dashboard
Open a device (or a directory, to roll out to a whole subtree) and choose Deploy code. In the dialog you:
- Add your files: one or more
.py(or.mpy), plus any assets - Entry module (optional): which module starts the app (see
- Scan files with Surface (on by default): each file is malware-scanned
- Name (optional): a label so you can tell deployments apart.
(.tflite, .json, .bin). The server computes each file's size and SHA-256 and stores the bundle.
The entry module). Leave blank to use the device's app_entry.
before the deployment can go out, using the scan profile from your account's file-transfer settings. A file that comes back Malicious blocks the whole deployment. Uncheck the box to skip scanning (files deploy without a malware check).
Push it, and the dialog tracks per-device rollout status live: notified → downloading → applied, or rolled_back / failed with the reason. Remote deployment is included on every plan, including Free; plans differ on fleet size, data, and retention, never on core capabilities.
What happens during a deployment
- Notify. The server sends the device a small update command over MQTT
- Download & verify. The device streams each file to a staging area and
- Snapshot & swap. The device snapshots the files it's about to replace,
- Validate. After reboot, your app runs. **A deployment is only kept once
(or the device picks it up on its next check-in; an offline device is updated when it next connects).
checks its size and SHA-256 against the manifest. Nothing goes live until every file verifies. If the device is short on flash, it refuses up front.
then swaps the new files into /app/ and reboots.
the device successfully reaches Tendrl again**, and that's the definition of a good deployment.
Automatic rollback
If the new code doesn't work, the device reverts to the previous version on its own; you don't have to do anything, and you don't have to drive to the device:
- A crash on startup (including a syntax error) is caught, and the previous
- A boot loop, meaning repeated resets without a successful check-in, trips a
- Losing connectivity: if the new code runs but can't reach Tendrl within
- Power loss mid-update is recovered on the next boot from a journal, so a
version is restored.
ceiling and rolls back.
the validation window, it rolls back.
half-applied update can't brick the device.
After a rollback, the device reports what happened (the captured error) the next time it connects, so you can see it in the dashboard.
What to keep in mind
- Keep your entry module's import path stable (
/app/main.pyby default). - The updater needs enough free flash for the new files plus a snapshot of what
- Deployments carry your
.py/.mpycode and small assets (models, sidecar
they replace; it checks before downloading and refuses cleanly if short.
JSON, blobs) — not media. SDK self-update travels the same channel as an "sdk" deployment.
Remote deployment covers your application code. Updating the MicroPython firmware itself is a rare, deliberate step that still happens over USB, see Flashing Firmware.
Tendrl