- An agent half (a subprocess on the drone, written in Python or Rust).
- A GCS half (TypeScript bundle in a sandboxed iframe).
- One or more drivers registered by the agent half.
agent.runtime: python (the
default) or agent.runtime: rust. A Python half uses the ados.sdk
package; a Rust half links the ados-sdk crate. Both speak the same
length-prefixed msgpack wire to the agent’s plugin host, so a Rust agent
half and a Python or Rust host interoperate without changes. The GCS half
uses the @altnautica/plugin-sdk TypeScript SDK regardless of which
language the agent half is written in.
When to ship two halves
Ship two halves when the plugin needs both of:- Hardware or low-level system access on the drone.
- An operator-facing UI in the GCS.
hardware.usb.uvc on the drone (driver half)
plus a video overlay in the GCS.
Do not split work that belongs in one half. If the plugin only
needs telemetry and renders a panel, it is GCS-only. If the
plugin only does background analytics with no operator UI, it is
agent-only.
Lifecycle ordering
Install order is fixed:- Operator drops
.adospluginto the GCS install dialog. - Manifest is parsed (no disk writes).
- Operator approves permissions. Both halves’ permissions appear in one grid.
- Host calls install on both halves in parallel. The agent host unpacks the agent half; the GCS host registers the bundle in the user’s install record.
- On enable, the agent half starts first as its own systemd
subprocess (the Python interpreter for a
runtime: pythonhalf, or the cross-compiled binary for aruntime: rusthalf). - Once the agent half is running, the GCS half mounts in its iframe. The GCS bridge gates outbound RPC to the agent until the agent half is listening.
SIGTERM to the agent.
Cross-half communication
The two halves do not speak directly. They speak through the host’s event bus.plugin.<id>.* topics. The agent half publishes on
the full topic, prefixed with plugin. and the plugin id:
plugin.<your-id>. is always yours to publish
and to subscribe to. Any other topic falls under the event.publish and
event.subscribe capabilities and is checked against the allowlist:
reserved namespaces (vehicle., mavlink., mission., safety.,
agent., swarm., gps.) cannot be published into, and a small set of
public safety and lifecycle topics (vehicle.armed, mission.started,
agent.ready, and similar) can be subscribed to without a per-topic
allowlist entry. The GCS half receives host-pushed events through
ctx.events.subscribe, so it does not open a network connection of its
own.
Shared manifest
One manifest. Two contributing blocks:id, name, version, license, risk) are
flat, not nested under a plugin: block. Permissions are a list of
- id: <capability> entries. Both halves carry the same id and
version, so the host treats them as one install row.
Version compatibility between halves
Both halves ship in the same archive, so they always carry the same version. The supervisor refuses to load mismatched halves; that case can only happen if someone unpacks an archive and hand-edits one side. What can drift across versions:- Topic schemas published from the agent and consumed by the GCS.
Use a
schema_versionfield in your published payload and branch on it in the GCS. - Persistent state on disk written by the agent and read by the
GCS via Convex. Carry a
data_versionin those rows.
Driver halves
Drivers live inside the agent half. In Python you implement one of the typed driver base classes (CameraDriver, GimbalDriver, LidarDriver,
GpsDriver, EscDriver, PayloadActuatorDriver); in Rust you implement
the matching driver trait. Either way you register the instance with the
peripheral manager. See driver layer
for the contract.
A plugin can ship multiple drivers. A thermal camera plugin registers a
CameraDriver for the sensor. A multi-sensor plugin can register a
CameraDriver plus a LidarDriver from the same agent half:
sensor.camera.register, sensor.lidar.register). Both must be
declared in the manifest.
Worked example: hybrid plugin skeleton
agent/plugin.py), the
runner imports a class named Plugin and calls its lifecycle hooks:
mount call: