> ## Documentation Index
> Fetch the complete documentation index at: https://docs.altnautica.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Multi-component plugins

> Patterns for plugins that span agent, GCS, and driver halves. Lifecycle ordering, cross-half RPC, version compatibility.

A plugin can ship up to three pieces in one archive:

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

Each half has its own runtime, its own SDK, and its own sandbox.
What makes them one plugin is the shared manifest, shared id,
shared version, and shared install state.

The agent half declares its language with `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.

The Battery Health plugin is GCS-only because it reads normalized
telemetry and renders a chart. The Thermal Camera plugin is
hybrid because it needs `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:

1. Operator drops `.adosplug` into the GCS install dialog.
2. Manifest is parsed (no disk writes).
3. Operator approves permissions. Both halves' permissions appear
   in one grid.
4. 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.
5. On enable, the **agent half starts first** as its own systemd
   subprocess (the Python interpreter for a `runtime: python` half, or
   the cross-compiled binary for a `runtime: rust` half).
6. 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.

Disable runs in reverse: GCS unmounts first, then the supervisor
sends `SIGTERM` to the agent.

## Cross-half communication

The two halves do **not** speak directly. They speak through the
host's event bus.

```
GCS plugin (iframe)
  | postMessage RPC
  v
Mission Control host
  | mqtt or local socket
  v
ADOS Drone Agent host
  | unix-socket IPC
  v
Agent plugin (subprocess)
```

Use the plugin's own `plugin.<id>.*` topics. The agent half publishes on
the full topic, prefixed with `plugin.` and the plugin id:

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
# agent half: publish on the plugin's own topic
await ctx.events.publish(
    "plugin.com.example.thermal.frame.ready",
    {"frame_id": 42, "ts_ms": now_ms()},
)
```

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
// GCS half (same plugin id): subscribe to the host-pushed event
ctx.events.subscribe("plugin.com.example.thermal.frame.ready", (evt) => {
  redrawOverlay(evt.frame_id);
});
```

A topic that starts with `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:

```yaml theme={"theme":{"light":"github-light","dark":"github-dark"}}
schema_version: 1
id: com.example.thermal
name: "Thermal Camera"
version: "0.1.0"
description: "Radiometric thermal imaging with a video overlay and FC tab."
license: "GPL-3.0-or-later"
risk: medium

compatibility:
  ados_version: ">=0.10.0"
  gcs_version: ">=0.5.0"
  supported_boards: ["cm4", "cm5", "rk3576", "rk3566"]

agent:
  entrypoint: "agent/plugin.py"
  isolation: subprocess
  permissions:
    - id: event.publish
    - id: event.subscribe
    - id: hardware.usb.uvc
    - id: sensor.camera.register
  resources:
    max_ram_mb: 192
    max_cpu_percent: 35
    max_pids: 16

gcs:
  entrypoint: "gcs/plugin.bundle.js"
  isolation: iframe
  permissions:
    - id: ui.slot.video-overlay
    - id: ui.slot.fc-tab
    - id: telemetry.subscribe.thermal
  contributes:
    panels:
      - id: thermal-overlay
        slot: video.overlay
      - id: thermal-tab
        slot: fc.tab
  locales:
    - en
```

The top-level fields (`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_version` field 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_version` in those rows.

The SDK does not inspect topic payload schemas; it is your job
to keep them backward compatible inside a major version.

## 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](/developers/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:

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
async def on_start(self, ctx) -> None:
    cam = ThermalCameraDriver()
    lidar = ExternalLidarDriver()
    await ctx.peripherals.register_camera_driver(cam)
    await ctx.peripherals.register_lidar_driver(lidar)
```

Each registration is gated by its own capability
(`sensor.camera.register`, `sensor.lidar.register`). Both must be
declared in the manifest.

## Worked example: hybrid plugin skeleton

```
com.example.thermal/
├── manifest.yaml
├── agent/
│   ├── plugin.py
│   └── thermal_driver.py
├── gcs/
│   ├── plugin.bundle.js
│   └── overlay.tsx
├── locales/
│   └── en.json
└── icon.png
```

Agent entry point. For a file-path entrypoint (`agent/plugin.py`), the
runner imports a class named `Plugin` and calls its lifecycle hooks:

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
from ados.sdk import CameraDriver
from .thermal_driver import ThermalDriver

class Plugin:
    async def on_start(self, ctx) -> None:
        driver = ThermalDriver()  # subclasses CameraDriver
        await ctx.peripherals.register_camera_driver(driver)
```

GCS entry point. The manifest declares which panel renders in which
slot; the bundle sets up its UI from the single `mount` call:

```tsx theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { definePlugin } from "@altnautica/plugin-sdk";
import { mountUI } from "./ui";

definePlugin({
  id: "com.example.thermal",
  version: "0.1.0",
  mount(ctx, info) {
    mountUI(ctx);
  },
});
```

## Failure modes

| Scenario                                             | What the host does                                                                                                        |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Agent half crashes, GCS still running.               | GCS half stays mounted; outbound RPC to the agent returns `handler_error`. The GCS half should render an "offline" state. |
| GCS half fails to load (bundle 404).                 | Agent half keeps running. The slot orchestrator marks the iframe failed and shows an error tile.                          |
| Operator revokes a permission used by only one half. | That half's affected calls return `permission_denied`; the other half is unaffected.                                      |

## See also

* [Agent plugin deep dive](/developers/agent-plugin-deep-dive)
* [GCS plugin deep dive](/developers/gcs-plugin-deep-dive)
* [Driver layer](/developers/driver-layer)
* [Versioning and updates](/developers/versioning-and-updates)
