Docs

Build a custom Remote Operator plugin

This example builds a plugin for Remote Operator, the part of Alien that runs named, approved actions inside a customer's deployment instead of giving your team shell access. The plugin adds two actions: check the application's health, and change how many exports it allows per minute.

Architecture diagram. Left: Your cloud containing Alien manager, Your team or agent, operations invoke, Approvals. Right: Customer environment containing Your application containing Operator; custom-ops plugin.

The Operator pulls approved invocations, runs the plugin as a short-lived process, and calls your application's admin API locally.

What you build:

  • A Rust plugin with one read-only operation (doctor) and one mutating operation (throttle-export-automation).
  • Typed parameters and a generated metadata.json, so bad input never reaches your application.
  • Approval rules: the customer approves throttle-export-automation with {"maxExportsPerMinute": 12}, not a terminal.

Source: examples/custom-operations.

In your stack

A deployment runs only the plugins its release declares. Once published, the plugin is one entry under plugins in .operations(), pinned to an exact version, with approval rules:

alien.ts
export default new alien.Stack("acme")
  // ...resources
  .operations({
    plugins: [
      {
        name: "custom-ops",
        version: "0.1.0",
        approval: {
          doctor: "auto",
          "throttle-export-automation": "manual",
        },
      },
    ],
  })
  .build()
  • doctor runs without approval.
  • The throttle change needs an approved access request. So does any operation with no matching rule.
  • Operation names in approval leave out the plugin prefix.

The declaration takes effect on the next alien release.

Why not a shell

With shell access, the answer to "what could that person have done?" is "anything the shell allows". A narrow operation answers in advance: it has a name, typed parameters, a declared risk tier, and nothing else.

Remote Operator ships built-in plugins for Kubernetes, Postgres, S3, and other services. A custom plugin covers what only your application knows about, such as its own admin endpoints.

How a plugin runs

A plugin is a Linux executable in a zip bundle, next to a metadata.json that describes its operations. When someone invokes an operation:

  1. The operation's risk tier and the stack's approval rules decide whether it runs right away or needs an approved access request.
  2. The Operator, Alien's process in the customer's environment, starts the plugin.
  3. It writes the invocation to the plugin's stdin as JSON and reads one result from stdout. Parameters never appear on the command line.

The plugin starts with a cleared environment. It keeps only infrastructure variables such as PATH, proxies, TLS settings, and cloud credentials, plus the settings the stack gives that plugin. One plugin never sees another plugin's settings.

Types

src/lib.rs starts with the parameter and result types, as ordinary Rust structs. schemars turns them into JSON Schema, and deny_unknown_fields rejects any field the operation doesn't expect:

examples/custom-operations/src/lib.rs
#[derive(Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct DoctorParams {}

#[derive(Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct DoctorOutput {
    /// Application-reported health status, for example "healthy".
    status: String,
    /// Maximum exports the application currently permits per minute.
    max_exports_per_minute: NonZeroU32,
}

#[derive(Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ExportThrottle {
    /// Maximum exports the application permits per minute.
    max_exports_per_minute: NonZeroU32,
}

NonZeroU32 matters here: zero would stop all exports, an outage rather than a throttle. Both the runtime decoder and the generated schema ("minimum": 1.0) reject it.

Operations

A TypedOperations registry holds every operation. Each one is an OperationDefinition (name, risk tier, description, permissions, timeout) plus an async handler that takes the typed parameters:

examples/custom-operations/src/lib.rs
/// Build the registry for a fixed application admin endpoint.
/// The URL belongs to plugin configuration, not caller params.
pub fn operations(base_url: &str) -> Result<TypedOperations> {
    let mut operations = TypedOperations::new();
    let doctor_url = format!("{}/health", base_url.trim_end_matches('/'));
    operations.register(
        OperationDefinition::new(
            "doctor",
            RiskTier::ReadOnly,
            "Read application health and export throttle.",
        )
        .with_no_permissions()
        .with_timeout_seconds(15),
        move |_params: DoctorParams| {
            let url = doctor_url.clone();
            async move {
                http_client()?
                    .get(url)
                    .send()
                    .await
                    .map_err(|_| request_failed())?
                    .error_for_status()
                    .map_err(|_| request_failed())?
                    .json::<DoctorOutput>()
                    .await
                    .map_err(|_| response_invalid())
            }
        },
    )?;

The mutating operation has the same structure, with RiskTier::Mutating and a PUT:

examples/custom-operations/src/lib.rs
    let throttle_url = format!("{}/admin/export-throttle", base_url.trim_end_matches('/'));
    operations.register(
        OperationDefinition::new(
            "throttle-export-automation",
            RiskTier::Mutating,
            "Set the application's export limit per minute.",
        )
        .with_no_permissions()
        .with_timeout_seconds(15),
        move |params: ExportThrottle| {
            let url = throttle_url.clone();
            async move {
                http_client()?
                    .put(url)
                    .json(&params)
                    .send()
                    .await
                    .map_err(|_| request_failed())?
                    .error_for_status()
                    .map_err(|_| request_failed())?
                    .json::<ExportThrottle>()
                    .await
                    .map_err(|_| response_invalid())
            }
        },
    )?;
    Ok(operations)
}

The plugin limits what a caller can reach and what a result can reveal:

  • The URL is not a parameter. It comes from the plugin's configuration, so a caller can change the throttle but can't point the plugin at another host.
  • .with_no_permissions() says the plugin needs no cloud IAM or Kubernetes API access; it only talks to the application over the network. If an operation needs cloud access, declare it with .with_permissions(...) and Alien grants exactly that during initial setup, the one-time step the customer's admin runs.
  • Errors are stable codes, not raw messages. A failure returns APPLICATION_UNAVAILABLE or APPLICATION_RESPONSE_INVALID, never the application's error body, so a result can't leak customer data.

The HTTP client times out after ten seconds, below the operation's 15-second timeout, and doesn't follow redirects:

examples/custom-operations/src/lib.rs
fn http_client() -> std::result::Result<Client, OperationFailure> {
    Client::builder()
        .timeout(Duration::from_secs(10))
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .map_err(|_| OperationFailure::new("CLIENT_UNAVAILABLE", "could not create HTTP client"))
}

Entry point

src/main.rs reads CUSTOM_OPS_BASE_URL, checks that it's a plain HTTP or HTTPS base URL, builds the registry, and hands it to run_plugin:

examples/custom-operations/src/main.rs
#[tokio::main]
async fn main() -> ExitCode {
    let registry = std::env::var("CUSTOM_OPS_BASE_URL")
        .map_err(|_| "set CUSTOM_OPS_BASE_URL to the application's admin URL".to_string())
        .and_then(|value| Url::parse(&value).map_err(|_| "invalid CUSTOM_OPS_BASE_URL".to_string()))
        .and_then(|url| {
            if !matches!(url.scheme(), "http" | "https")
                || url.host_str().is_none()
                || url.query().is_some()
                || url.fragment().is_some()
            {
                return Err(
                    "CUSTOM_OPS_BASE_URL must be an HTTP base URL without a query or fragment"
                        .to_string(),
                );
            }
            Ok(url)
        })
        .and_then(|url| operations(url.as_str()).map_err(|error| error.to_string()));
    match registry {
        Ok(registry) => run_plugin(registry).await,
        Err(message) => {
            eprintln!("{message}");
            ExitCode::FAILURE
        }
    }
}

run_plugin does the rest:

  1. Reads one invocation from stdin and checks the protocol version.
  2. Dispatches by operation name and validates the parameters against the type.
  3. Writes one result to stdout.

You write no operation-name match and no hand-maintained schema. The SDK returns these errors before your handler runs:

CodeWhen
INVALID_PARAMSParameters don't decode into the type: missing field, 0, -1, or an extra field
OPERATION_UNKNOWNThe operation name isn't registered
PLUGIN_PROTOCOL_VERSION_UNSUPPORTEDThe Operator speaks a protocol version the plugin doesn't

Metadata

metadata.json is generated from the same registry, so it can't drift from the code. plugin_manifest builds the registry against a placeholder URL, without contacting the application, and adds the bundle details:

examples/custom-operations/src/lib.rs
/// Generate bundle metadata without contacting the application.
pub fn plugin_manifest() -> Result<CanonicalPluginManifest> {
    let registry = operations("http://localhost")?;
    let manifest = CanonicalPluginManifest {
        name: "custom-ops".to_string(),
        version: env!("CARGO_PKG_VERSION").to_string(),
        tier: RiskTier::Mutating,
        binaries: BTreeMap::from([
            (Arch::Amd64, "custom-ops-linux-amd64".to_string()),
            (Arch::Arm64, "custom-ops-linux-arm64".to_string()),
        ]),
        settings: Default::default(),
        operations: registry.manifests(),
    };
    manifest.validate()?;
    Ok(manifest)
}

src/bin/generate-metadata.rs writes it, or with --check fails when the file on disk is stale. Part of the result:

examples/custom-operations/metadata.json
{
  "name": "throttle-export-automation",
  "tier": "mutating",
  "description": "Set the application's export limit per minute.",
  "inputSchema": {
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "ExportThrottle",
    "type": "object",
    "required": ["maxExportsPerMinute"],
    "properties": {
      "maxExportsPerMinute": {
        "description": "Maximum exports the application permits per minute.",
        "type": "integer",
        "format": "uint32",
        "minimum": 1.0
      }
    },
    "additionalProperties": false
  },
  "permissions": [],
  "timeoutSeconds": 15
}

alien operations check, package, and publish all require metadata generated this way:

  • check and package fail without a src/bin/generate-metadata.rs.
  • publish rejects operations without generated input and output schemas.

The application contract

The plugin expects two endpoints on the application's admin API. GET /health returns:

{"status":"healthy","maxExportsPerMinute":100}

PUT /admin/export-throttle accepts and returns:

{"maxExportsPerMinute":12}

The application must finish applying the new limit before it responds, so the result the operator sees is the state the application is in.

Run the tests

The example builds inside the alien repository's Cargo workspace. From the repository root:

git clone https://github.com/alienplatform/alien.git
cd alien
cargo run -p custom-ops --bin generate-metadata
cargo run -p custom-ops --bin generate-metadata -- --check
cargo nextest run -p custom-ops
alien operations check examples/custom-operations

The tests in tests/plugin.rs run the compiled plugin exactly as the Operator does:

  1. Start a fake application on a random loopback port.
  2. Run the plugin binary with the invocation on stdin and CUSTOM_OPS_BASE_URL in the environment.
  3. Check the application's real state afterwards.
examples/custom-operations/tests/plugin.rs
let doctor = success(invoke(&url, PluginInvocation::inline_json("doctor", b"{}")).await);
assert_eq!(
    doctor,
    json!({ "status": "healthy", "maxExportsPerMinute": 100 })
);
let changed = success(
    invoke(
        &url,
        PluginInvocation::inline_json(
            "throttle-export-automation",
            br#"{"maxExportsPerMinute":12}"#,
        ),
    )
    .await,
);
assert_eq!(changed, json!({ "maxExportsPerMinute": 12 }));
assert_eq!(app.throttle.load(Ordering::SeqCst), 12);

A second test sends {}, 0, -1, an extra field, an unknown operation name, and an unsupported protocol version. It asserts the application received zero requests and its throttle is still 100: bad input never reaches the application.

A third test compares the checked-in metadata.json with the registry's output.

Package and publish

On a Linux machine:

alien operations package examples/custom-operations
alien operations publish ./custom-ops-0.1.0.zip

package runs the metadata check, builds a release binary for the host's architecture, and zips it with metadata.json. That bundle supports only that architecture.

To support both amd64 and arm64, build on each Linux architecture. Put both executables, named as in metadata.json (custom-ops-linux-amd64, custom-ops-linux-arm64), into one zip with the generated metadata.json before publishing.

Before publishing, preview the plugin's permission grants offline with alien operations permissions examples/custom-operations --cloud aws. This plugin declares none.

Then add the plugin to your stack as shown in In your stack and run alien release.

Invoke the operations

alien operations invoke --deployment customer-1/production --operation custom-ops/doctor

alien operations invoke --deployment customer-1/production \
  --operation custom-ops/throttle-export-automation \
  --params '{"maxExportsPerMinute":12}' \
  --request-access --access-duration 1h

The first call returns {"status":"healthy","maxExportsPerMinute":100}.

The second creates an access request for that exact operation and parameters, waits for the customer to approve it, and then runs. Without --request-access, the CLI prints Access is required to run custom-ops/throttle-export-automation. and the command to request it.

Use alien operations invoke for anything that should go through risk tiers and customer approval. Use commands (alien commands invoke) for application RPC handlers in your own code, which have no approval rules.

What to change for production

  • Pass the admin URL as a plugin setting. The plugin starts with a cleared environment, so CUSTOM_OPS_BASE_URL must come from a setting the stack provides, and the example's manifest declares none (settings: Default::default()). Declare one, for example ("baseUrl".to_string(), PluginSettingManifest::string("CUSTOM_OPS_BASE_URL", "Application admin API URL").required()), regenerate metadata.json, and set baseUrl next to approval in the stack's plugin entry.
  • Add your application's authentication. The example expects an admin API with no HTTP authentication on a trusted network. Add a secret setting for the credential and send it from the HTTP client. Keep credentials in plugin settings, never in caller parameters.
  • Use HTTPS for any endpoint that isn't on loopback or an explicitly trusted application network.
  • Keep each operation narrow: one question or one repair. Avoid general shells, arbitrary SQL, or pass-through proxies, and keep results free of customer records and secrets.

Adapt it

Prompt
Use the alien custom-operations example (github.com/alienplatform/alien/tree/main/examples/custom-operations) as a model. Write a Rust Remote Operator plugin for my application with alien-operations-sdk: typed params and results with serde deny_unknown_fields and schemars, a TypedOperations registry with one read-only diagnostic operation and one narrow mutating repair, stable error codes that never echo application responses, a generate-metadata binary, and tests that run the compiled plugin against a fake application. Take the admin URL and credentials from plugin settings, never from caller params. Read https://alien.dev/docs/production/remote-operator/operations.md first.

Next

On this page