Docs

Commands

A command runs a function in a customer's environment when your backend asks, and returns the result. It needs zero inbound networking, so the customer's security team opens no ports, no VPN, and no firewall rules.

Architecture diagram. acme-docs deployment (search (Container), index (KV)) connects outbound to the Alien manager (Commands, Results), via https 443, outbound.

The deployment opens the connection, takes its commands, and returns the results. Nothing connects in.

Polling over HTTPS

The deployment connects out to Alien over port 443, takes the commands waiting for it, runs them, and sends back the results. This works on every platform, for every Container and Daemon, including on AWS, Google Cloud, and Azure.

The handler runs in a Container, next to the customer's data:

search/src/index.ts
import { createCommandReceiver } from "@alienplatform/commands"
import { kv } from "@alienplatform/sdk"
import { z } from "zod"

const index = kv("index")
const receiver = createCommandReceiver()

const SearchInput = z.object({
  prefix: z.string(),
  limit: z.number().max(100),
})

receiver.command("search", SearchInput, async ({ prefix, limit }) => {
  const page = await index.scan(`doc:${prefix}`, limit)
  const keys = page.items.map(item => item.key)
  return { results: keys }
})

await receiver.run()

Your backend calls it by name and waits for the result:

backend/search.ts
import { CommandsClient } from "@alienplatform/commands"

const commands = await CommandsClient.forDeployment({
  deploymentId: customer.deploymentId,
  apiKey: process.env.ALIEN_API_KEY!,
})

const input = { prefix: "contracts/", limit: 20 }
const { results } = await commands.invoke<{ results: string[] }>("search", input)

Your backend often needs an answer that only the customer's environment can give: a search over documents that can't leave their account, a query against a private database, a check on a host. Only what the handler returns leaves the environment: here, document keys, not document contents.

Commands are one of several ways your backend reaches a deployment; see Backend connections to compare them.

Push to Workers

Workers, Alien's serverless compute, can also receive commands pushed through the cloud's API. On AWS a Worker runs on Lambda, on Google Cloud on Cloud Run, and on Azure on Container Apps.

Architecture diagram. acme-docs deployment (processor (Worker), index (KV)) is reached inbound from the Alien manager (Commands, Results), via push, cloud API.

The manager invokes the Worker through the cloud provider's API, the moment the command is created. The customer's network accepts no connections.

The manager calls the Worker through Lambda, Pub/Sub, or Service Bus, with the management identity that initial setup created. Initial setup is the one-time step the customer's admin runs; see Frozen and live.

A Worker registers handlers with command(). No receiver loop is needed:

processor/src/index.ts
import { command, kv } from "@alienplatform/sdk"
import { z } from "zod"

const index = kv("index")

const SearchInput = z.object({
  prefix: z.string(),
  limit: z.number().max(100),
})

command("search", SearchInput, async ({ prefix, limit }) => {
  const page = await index.scan(`doc:${prefix}`, limit)
  const keys = page.items.map(item => item.key)
  return { results: keys }
})

Your backend sends to a Worker exactly as it sends to a Container. On Kubernetes, a Worker's commands arrive by polling instead: the Operator, Alien's agent in the cluster, takes them over outbound HTTPS and calls the Worker inside the cluster. See Rust SDK for the full Worker setup.

Enable commands

Turn on commands for each resource that handles them in alien.ts:

alien.ts
const search = new alien.Container("search")
  .code({ type: "image", image: "ghcr.io/acme/search:1.0.0" })
  .link(index)
  .commandsEnabled(true)
  .permissions("search")
  .build()

Worker and Daemon take the same .commandsEnabled(true).

  • The schema validates and types the parameters. Any Standard Schema library works, such as zod or valibot. In Rust, the input type implements Deserialize.
  • The return value goes back as JSON.
  • Python workloads can't receive commands.

Delivery is at least once. The handler's context carries commandId, attempt, and the optional deadline, so a handler that changes data can detect a redelivery:

processor/src/index.ts
const ReindexInput = z.object({ key: z.string() })

command("reindex", ReindexInput, async ({ key }, { attempt }) => {
  // ...rebuild the index entry for key; safe to run twice
  return { key, attempt }
})

Send from your backend

CommandsClient from @alienplatform/commands needs the deployment ID and a server-side Alien API key, as in the example at the top of this page.

Create the key with alien api-keys create --for deployments, or under Settings → API Keys in the dashboard. Keep it on your server.

.target() names the resource that hosts the handler:

backend/search.ts
const { results } = await commands
  .target("search")
  .invoke<{ results: string[] }>("search", input)

It's required when more than one resource in the stack has commands enabled. Without it, the invoke is rejected with COMMAND_TARGET_AMBIGUOUS.

Reuse the client. It refreshes its short-lived, command-only access before it expires, and once after a 401 from the manager.

Private manager

For a private manager, one you run in your own cloud, pass its URL and a token instead. Rust backends use the alien-commands-client crate this way:

backend/search.ts
const commands = new CommandsClient({
  managerUrl: "https://manager.example.com",
  deploymentId: customer.deploymentId,
  token: process.env.ALIEN_COMMANDS_TOKEN!,
})

const { results } = await commands
  .target("search")
  .invoke<{ results: string[] }>("search", input)

How delivery works

Every command takes the same path through the manager, the Alien server that tracks your deployments:

  1. Your backend creates the command with POST /v1/commands. The manager stores it.
  2. The manager delivers it to the target resource, by polling or by push.
  3. The handler runs in the deployment and posts its result back to the manager.
  4. Your backend's invoke() checks the command's status until it has a result. It checks after 500 ms, then backs off up to every 5 seconds.

The target resource and the deployment model decide the delivery:

TargetDeliveryPickup time
Container or Daemon, any platformA receiver in your process polls over outbound HTTPS: every 5 seconds, slowing to every 30 seconds while idleUp to about 30 seconds when idle
Worker on Kubernetes, or any pull deploymentThe Operator polls every 5 seconds over outbound HTTPS and calls the Worker inside the environmentUp to about 5 seconds
Worker in a push deployment (AWS, Google Cloud, Azure)The manager invokes the Worker through Lambda, Pub/Sub, or Service Bus when the command is createdImmediate

What you set up for each:

  • Container or Daemon: run a receiver in your process; see Containers and Daemons. The workload needs outbound HTTPS to the manager.
  • Worker: nothing beyond command() handlers. The Operator is part of every pull deployment, and push uses the management identity.

Polling is how a command reaches an environment behind a firewall: the deployment opens the connection on port 443, so the network needs no inbound rule. Push needs no network path at all, because the manager calls the cloud provider's API, not the customer's network.

Your handler code is the same either way. Moving a customer from push to pull changes who delivers a Worker's commands, not how the handler receives them.

Payloads

Parameters and results are JSON. Up to 150 KB, they travel inside the command. Larger ones go through the manager's storage with signed URLs, without any change to your code.

Send IDs and small inputs, and return the smallest result your backend needs. For files, return a signed URL to the customer's own storage instead of the bytes.

Timeouts and failures

invoke() waits 60 seconds by default. Change it with timeoutMs on the client or on each call. When it runs out, invoke() throws COMMAND_TIMEOUT, but the command isn't canceled: it can still be delivered and run later.

To stop a command that hasn't finished in time, pass a deadline:

backend/search.ts
await commands.invoke("search", input, {
  timeoutMs: 30_000,
  deadline: new Date(Date.now() + 30_000),
  idempotencyKey: `search-${requestId}`,
})
  • A command without a result by its deadline is marked expired and isn't delivered after that. invoke() throws COMMAND_EXPIRED.
  • idempotencyKey deduplicates retried creates: the same key for the same target returns the existing command.
  • A Worker's handler runs within the Worker's timeout (.timeoutSeconds(), default 180 seconds).
  • When the Operator delivers to a Worker, it holds the command for the Worker's timeout plus 60 seconds. If no result arrives by then, the command is delivered again.

A handler error reaches your backend as DEPLOYMENT_COMMAND_ERROR, with the handler's code:

Error codeCause
UNKNOWN_COMMANDNo handler registered for the name
HANDLER_ERRORThe handler threw. An AlienError thrown by the handler keeps its own code.
HANDLER_TIMEOUTA receiver handler's budget expired
DELIVERY_FAILEDThe Worker's runtime refused the push

Send from the CLI

Use the CLI for testing and support:

alien commands invoke \
  --deployment customer-1/production \
  --command search \
  --params '{"prefix": "contracts/", "limit": 20}'
  • --deployment takes a deployment ID or <deployment-group>/<deployment-name>. A deployment group holds a customer's deployments, usually one group per customer.
  • --target names the resource when more than one has commands enabled.
  • --timeout sets the wait in seconds (default 60).

Against a local alien dev session, use alien dev commands invoke --deployment local-dev/default ....

The dashboard doesn't send commands. Operations → Commands lists the commands dispatched to the project's deployments.

Containers and Daemons

A Container or Daemon has no Worker runtime in front of it, so your process runs the receiver, as in the example at the top of this page. Alien doesn't start a sidecar, and the Operator doesn't relay a Container's commands. If your process doesn't run a receiver, its commands are never picked up.

  • createCommandReceiver() from @alienplatform/commands uses plain fetch with no native addon, so it runs in any Node 18+ or Bun process.
  • Rust services use alien_commands::Receiver::from_env() from the alien-commands crate, with the receiver feature.
  • Run it alongside whatever else the process does, such as serving HTTP.

The receiver reads environment variables that Alien injects when .commandsEnabled(true) is set:

VariableValue
ALIEN_COMMANDS_URLBase URL of the manager's command API (http or https)
ALIEN_COMMANDS_TOKEN or ALIEN_COMMANDS_TOKEN_FILEBearer token, or a file holding it. The file is reread once after a 401, so the token can rotate without a restart.
ALIEN_DEPLOYMENT_IDThe deployment the commands belong to
ALIEN_COMMANDS_TARGET_RESOURCE_IDThis resource's ID in the stack
ALIEN_COMMANDS_TARGET_RESOURCE_TYPEcontainer or daemon. worker is rejected.

A missing or invalid value throws COMMAND_RECEIVER_CONFIG_INVALID naming the variable, so a misconfigured process fails at startup instead of polling nothing.

Lease loop

Each check asks the manager for up to ALIEN_COMMANDS_MAX_LEASES commands and holds each one for a lease. The matching createCommandReceiver() options override these variables:

VariableDefaultMeaning
ALIEN_COMMANDS_POLL_INTERVAL_MS5000Time between checks while commands arrive
ALIEN_COMMANDS_POLL_MAX_INTERVAL_MS30000Longest time between checks; an empty check doubles the wait up to this
ALIEN_COMMANDS_POLL_JITTER0.1Random spread applied to each wait
ALIEN_COMMANDS_LEASE_SECONDS60How long a leased command stays with this receiver
ALIEN_COMMANDS_MAX_LEASES1Commands handled at once
ALIEN_COMMANDS_DRAIN_TIMEOUT_MS30000Time in-flight handlers get after stop()

A receiver handler runs under a budget of the command's deadline or the lease expiry minus 5 seconds, whichever comes first. When it runs out, signal fires (ctx.cancellation in Rust), the result is discarded, and the sender gets HANDLER_TIMEOUT.

Leases aren't renewed, so size long work to the lease. A lease that expires before a result is submitted is delivered again, which is why handlers must tolerate running twice.

The handler context has signal, deadline, commandId, attempt, target, and an optional W3C traceContext. For non-JSON parameters, receiver.handleRaw(name, handler) passes the raw bytes as ctx.input.

run() leases commands until you call receiver.stop(). In Rust, take receiver.shutdown_handle() before run() and call shutdown() on it. On stop, the receiver stops leasing and gives in-flight handlers the drain timeout before it cancels them and releases their leases.

Commands and operations

Commands have no approval step. For actions that need the customer's approval, use Remote Operator operations.

To run an operation, a built-in action such as restarting a workload, use alien operations invoke. alien commands invoke only reaches your own handlers: given an operation's name, it fails with COMMAND_IS_OPERATION and prints the command to run instead.

Prompt
Read https://alien.dev/docs/guide/commands.md. Add a command to the deployed code in this repo that answers the backend's request, returning only the fields the backend needs. Call it from the backend with CommandsClient.forDeployment, pass a deadline, and add a test with @alienplatform/testing (https://alien.dev/docs/guide/testing.md).

Next

On this page