By the end of this you'll have scaffolded a real DeviceKit extension, installed it through the production install pipeline, called its HTTP route, and seen its automation step type render a full editor form with zero frontend code. Then you'll make it do something real.
You only need the backend running (Getting Started) — no device required for the first pass. Run the commands from the repo root.
The narrative behind every step is in the Extension Guide; the exhaustive field and SDK tables are the Manifest Reference and SDK Reference. This tutorial is the fast path.
The scaffolder (backend/scripts/new_extension.py) writes a manifest plus a backend/ package
wired to the SDK, using the same validate_manifest rules the installer enforces:
python backend/scripts/new_extension.py hello-fleet --backendIt creates a throwaway working tree at ./hello-fleet/ and prints exactly what it wrote and how to
install it:
[ok] Scaffolded 'hello-fleet' at .../hello-fleet
extension.json
backend/__init__.py
backend/routes.py
backend/steps.py
backend/lifecycle.py
Next: install it through the real pipeline (dev loop) —
curl -X POST localhost:5050/extensions/install-local -d '{"path": ".../hello-fleet"}'
Four files, each mapping 1:1 onto a manifest reference.
extension.json — the manifest. Note the module:attr refs pointing into backend/:
backend/routes.py — a Flask blueprint mounted at url_prefix:
from flask import Blueprint, jsonify
bp = Blueprint("hello_fleet", __name__)
@bp.route("/ping")
def ping():
return jsonify({"ok": True, "extension": "hello-fleet"})backend/steps.py — an automation step type. register() returns
{type_name: {label, category, config, execute}}; execute(client, config, device_id) runs when
the step executes:
def _execute(client, config, device_id):
return "hello-fleet step ran"
def register():
return {
"hello_fleet.hello": { # dashes in the slug become underscores
"label": "Hello Fleet Hello",
"category": "Hello Fleet",
"config": {"note": {"type": "text", "label": "Note", "required": False}},
"execute": _execute,
}
}backend/lifecycle.py — best-effort install/uninstall hooks using the SDK logger.
Same rules as the installer, so you catch a malformed manifest before a failed install:
python backend/scripts/new_extension.py --validate hello-fleet
# [ok] hello-fleet v0.1.0 — manifest validUse the exact command the scaffolder printed (the path is the absolute scaffold dir):
curl -X POST localhost:5050/extensions/install-local \
-H 'Content-Type: application/json' \
-d '{"path": "/abs/path/to/hello-fleet"}'install-local zips your working tree in memory (skipping .git / node_modules / __pycache__)
and pushes it through the same pipeline as a registry install: validate → persist the row →
Zip-Slip-safe extract → hot-load the blueprint and contributions. Confirm it landed:
curl localhost:5050/extensions
# {"extensions": [ { "name": "hello-fleet", "status": "active", ... } ], "count": ...}
curl localhost:5050/extensions/hello-fleet/ping
# {"ok": true, "extension": "hello-fleet"}That second call proves your blueprint is live on the running app — no restart.
Tip: install extensions with the backend in non-debug mode (
DEBUG_MODE=false python backend/app.py). Flask's debug reloader can race the hot-load and leave a half-registered blueprint. In productionDEBUG_MODEis already off.
Open the dashboard (http://localhost:5173) → Automations → New. In the step palette you'll
find Hello Fleet Hello under the Hello Fleet category. Add it: DeviceKit renders its config
form — a single Note text field — straight from the config metadata in your register().
You wrote no frontend code. That is the core payoff of a contributed step type.
Save the automation and Run it against any device; the step executes your _execute and returns
"hello-fleet step ran".
Edit backend/steps.py to actually act on the device, and declare the capability it needs. Say we
want the step to tap a coordinate from its config:
import devicekit_sdk
def _execute(client, config, device_id):
x = int(config.get("x", 540))
y = int(config.get("y", 1200))
devicekit_sdk.device_control("hello-fleet", device_id).tap(x, y) # needs "device.control"
return f"tapped ({x}, {y})"
def register():
return {
"hello_fleet.hello": {
"label": "Hello Fleet Tap",
"category": "Hello Fleet",
"config": {
"x": {"type": "number", "label": "X", "required": True},
"y": {"type": "number", "label": "Y", "required": True},
},
"execute": _execute,
}
}device_control(...).tap is permission-gated — add device.control to the manifest or it
raises PermissionDenied:
"permissions": ["device.control"],Reinstall to pick up the changes (install-local force-reinstalls by default):
curl -X POST localhost:5050/extensions/install-local \
-H 'Content-Type: application/json' -d '{"path": "/abs/path/to/hello-fleet"}'Re-open the editor — the step now shows X and Y fields. Run it against a real device and it taps.
Uninstall when you're done (add ?purge=1 to also drop any ext_hello_fleet_* tables):
curl -X DELETE localhost:5050/extensions/hello-fleet
rm -rf hello-fleet # the throwaway working treeWhere to next:
- More seams in one shot:
python backend/scripts/new_extension.py rich-ext --fullscaffolds a device-scoped extension withmodels,ai_tools,jobs+schedules, anautomation_templatesfile, and a/ext/<slug>blueprint. - Every contribution point explained: the Extension Guide.
- Look it up: Manifest Reference · SDK Reference.
- Real examples: the builtins in
builtin-extensions/—devicekit-browser(step types + gated AI tools + a device pool),devicekit-explorer(a builtin frontend),devicekit-notification-capture(jobs + owned table + bus forwarding).
{ "name": "hello-fleet", "display_name": "Hello Fleet", "version": "0.1.0", "category": "utility", "permissions": [], "min_devicekit_version": "1.0.0", "entry_point": "routes:bp", // Flask blueprint in backend/routes.py "url_prefix": "/extensions/hello-fleet", "step_types": "steps:register", // step type(s) in backend/steps.py "lifecycle": { "install": "lifecycle:on_install", "uninstall": "lifecycle:on_uninstall" } }