Docs

Build a command-only agent for employee devices

This example builds an agent for employee laptops that your backend can ask "what happened in the last hour?" or "scan this folder", with no open port on the device. Event data stays on the device until your backend asks for a specific slice.

Architecture diagram. Left: Your cloud, Your backend, Alien commands. Right: Employee device containing agent (Daemon) containing Command receiver; File watcher; PII scanner; events.db (encrypted).

The Daemon has no open port. It leases commands over outbound HTTPS and keeps its events in an encrypted local database.

You build a small Rust agent, a Daemon (a long-lived process on each device), that:

  • Watches folders for file activity.
  • Flags sensitive data such as emails and Social Security numbers.
  • Keeps events in an encrypted database on the device.
  • Answers your backend only through commands: it leases them over outbound HTTPS, runs the handler, and returns the answer.

An HTTP server on a laptop would need an open port, a certificate, and a firewall exception on every device. Pushing everything to your backend would move raw device activity off the device even when nobody looks at it. Commands avoid both.

Source: examples/endpoint-agent (Rust).

Parts

PartFileBehavior
File watchersrc/monitor.rsRecords file_created, file_modified, and file_removed events for the monitored paths
PII scannersrc/pii.rsMatches emails, SSNs, 16-digit card numbers, phone numbers, and keywords such as password and token
Encrypted storesrc/db.rsKeeps events in a local Turso database encrypted with AEGIS-256
Command handlerssrc/commands.rsget-events, get-config, scan-path, and simulate-clipboard

A Daemon with commands

A Daemon runs one long-lived process per machine, which is what a device agent is. .commandsEnabled(true) tells Alien to give the process what it needs to lease commands:

alien.ts
import * as alien from "@alienplatform/core"

// Encrypted local storage for events
const events = new alien.Storage("events").build()

const agent = new alien.Daemon("agent")
  .code({
    type: "source",
    src: ".",
    toolchain: {
      type: "rust",
      binaryName: "endpoint-agent",
    },
  })
  .link(events)
  .commandsEnabled(true)
  .permissions("execution")
  .build()

export default new alien.Stack("endpoint-agent")
  .platforms(["local"])
  .add(events, "frozen")
  .add(agent, "live")
  .permissions({
    profiles: {
      execution: {},
    },
  })
  .build()
  • The local platform runs the Daemon as a supervised process on the machine it's deployed to.
  • The execution profile grants no cloud permissions, because the agent touches nothing outside the device.
  • The events Storage resource is declared and linked, but the Rust code doesn't use it. Events go to the encrypted local database. Remove it, or use it to upload selected reports.

Command receiver

The agent's only interface is commands. main first opens the database and starts the background watchers:

src/main.rs
// Initialize encrypted database
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./.data".to_string());
let db = db::EncryptedDb::new(&data_dir, &encryption_key).await?;

tracing::info!("Encrypted database initialized");

// Start background monitoring
let db_clone = db.clone();
spawn(async move {
    if let Err(e) = monitor::watch_filesystem(&db_clone).await {
        tracing::error!("Filesystem monitoring error: {}", e);
    }
});

Then it builds a receiver from the environment Alien injects, registers the handlers, and runs it:

src/main.rs
// Build the app-owned pull command receiver from the injected environment
// and register the command handlers on it.
let mut receiver = alien_commands::Receiver::from_env()?;
commands::register(&mut receiver, db);

tracing::info!("Registered command handlers");

// Run the command receiver loop (blocks until shutdown)
receiver.run().await?;

Receiver comes from the alien-commands crate with only its receiver feature. It leases commands for this resource over outbound HTTPS, with no serverless runtime or Alien process in front of it.

Each handler takes typed JSON parameters and returns a JSON value:

src/commands.rs
pub fn register(receiver: &mut Receiver, db: EncryptedDb) {
    // get-events command
    {
        let db = db.clone();
        receiver.command("get-events", move |params: GetEventsParams, _ctx| {
            let db = db.clone();
            async move { Ok(handle_get_events(db, params).await?) }
        });
    }

    // get-config command
    receiver.command("get-config", move |_: serde_json::Value, _ctx| async move {
        Ok(handle_get_config().await?)
    });

    // scan-path command
    receiver.command(
        "scan-path",
        move |params: ScanPathParams, _ctx| async move { Ok(handle_scan_path(params).await?) },
    );

If a handler returns an error, the receiver submits it as a HANDLER_ERROR response, and the caller's invoke fails.

CommandParametersReturns
get-eventssince: a duration such as 30s, 5m, 1h, 2d. limit: optional, default 100{ "events": [{ "id", "eventType", "data", "timestamp" }] }, newest first
get-confignone{ "monitoredPaths": [...], "clipboardMonitoring": true, "eventRetentionDays": 30 }
scan-pathpath: a directory{ "filesScanned": n, "sensitiveFiles": [{ "path", "reason" }] }
simulate-clipboardcontent: a string{ "success": true }. For testing

Encrypted events on the device

The database is a local Turso file, events.db under DATA_DIR, encrypted with the AEGIS-256 cipher:

src/db.rs
let encryption_opts = EncryptionOpts {
    cipher: "aegis256".to_string(),
    hexkey: encryption_key.to_string(),
};

let db: Database = Builder::new_local(&db_path_str)
    .experimental_encryption(true)
    .with_encryption(encryption_opts)
    .build()
    .await
    .map_err(|e| Error::Database(format!("Failed to open encrypted database: {}", e)))?;

The key comes from DB_ENCRYPTION_KEY, a 64-character hex string. If it isn't set, the agent generates a random key and logs a warning.

A random key is lost on restart, so the database can't be reopened. Set DB_ENCRYPTION_KEY for anything you want to keep.

File watching and PII

The watcher uses the notify crate on the paths in MONITORED_PATHS, separated by colons. It defaults to the system temp directory. Each create, modify, or remove becomes an event row with the file path.

scan-path reads the files directly in one directory, not recursively. It checks .txt, .md, .json, .csv, and .log files under 1 MB, and reports the ones that match a pattern:

src/pii.rs
// SSN pattern (XXX-XX-XXXX)
if text.contains('-') {
    let ssn_regex = regex::Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap();
    if ssn_regex.is_match(text) {
        patterns_found.push("ssn".to_string());
    }
}

Clipboard content is never stored. Real clipboard monitoring needs platform APIs, so the example ships a simulate-clipboard command instead. It stores only a BLAKE3 hash of the content and the matched pattern names:

src/monitor.rs
// Compute hash of content (don't store actual content)
let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string();

// Detect PII
let pii_detection = pii::scan_text(content);

let event_data = json!({
    "contentHash": content_hash,
    "hasPII": pii_detection.has_pii,
    "patternsFound": pii_detection.patterns_found,
});

db.insert_event("clipboard_write", &event_data).await?;

Run it locally

This example is not an alien init template. Run it from a clone of the repository:

Start the agent with a fixed key and a folder to watch:

git clone https://github.com/alienplatform/alien.git
cd alien/examples/endpoint-agent
pnpm install
mkdir -p /tmp/endpoint-demo
alien dev \
  --env MONITORED_PATHS=/tmp/endpoint-demo \
  --secret DB_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

In a second terminal, read the configuration:

alien dev commands invoke --deployment local-dev/default --command get-config
{
  "monitoredPaths": ["/tmp/endpoint-demo"],
  "clipboardMonitoring": true,
  "eventRetentionDays": 30
}

The CLI has no target flag. That works here because agent is the only resource with commands enabled.

Simulate a clipboard write that contains an email and a password, then read the events:

alien dev commands invoke --deployment local-dev/default --command simulate-clipboard \
  --params '{"content": "My email is user@example.com and password is secret123"}'

alien dev commands invoke --deployment local-dev/default --command get-events \
  --params '{"since": "24h", "limit": 10}'

The clipboard_write event holds a hash and the matched patterns, not the text:

{
  "events": [
    {
      "id": 1,
      "eventType": "clipboard_write",
      "data": {
        "contentHash": "<64 hex characters>",
        "hasPII": true,
        "patternsFound": ["email", "keyword:password", "keyword:secret"]
      },
      "timestamp": "<UTC time>"
    }
  ]
}

Write a file with an SSN and scan the folder:

echo "This contains my SSN: 123-45-6789" > /tmp/endpoint-demo/sensitive.txt
echo "Just normal content here" > /tmp/endpoint-demo/normal.txt

alien dev commands invoke --deployment local-dev/default --command scan-path \
  --params '{"path": "/tmp/endpoint-demo"}'
{
  "filesScanned": 2,
  "sensitiveFiles": [
    { "path": "/tmp/endpoint-demo/sensitive.txt", "reason": "Contains: ssn" }
  ]
}

A path that doesn't exist, or a since value such as invalid, makes the command fail.

Run the tests:

  • The Rust unit tests cover duration parsing and PII patterns.
  • The TypeScript test deploys the agent locally with @alienplatform/testing, sets MONITORED_PATHS and DB_ENCRYPTION_KEY, and calls each command with deployment.invokeCommand.
cargo test -p endpoint-agent
pnpm test

Call it from your backend

Your backend calls the same commands with @alienplatform/commands. .target("agent") names the resource explicitly:

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

const client = await CommandsClient.forDeployment({
  deploymentId: "dep_123",
  apiKey: process.env.ALIEN_API_KEY!,
})

const agent = client.target("agent")

const { events } = await agent.invoke("get-events", { since: "1h", limit: 50 })
const scan = await agent.invoke("scan-path", { path: "/Users/shared/Exports" })

See Commands for timeouts, idempotency keys, and how delivery works.

Deploy it to devices

Release the agent and create a deployment per device, or a deployment group (Alien's record of one customer, holding their deployments) for a team of devices:

alien login
alien link
alien release
alien onboard customer-1

For the initial setup, the one-time step, the customer's IT team installs alien-deploy on each device and runs it once with the deployment token you give them:

alien-deploy deploy --token-file /run/alien/token --platform local

--token-file keeps the token out of the process list. After that, later releases reach the device without anyone touching it. See alien-deploy.

Before production

  • Derive the key on the device. Don't ship one DB_ENCRYPTION_KEY to every device. Derive it from the TPM or Secure Enclave plus an Alien-managed secret with a KDF such as Argon2, and keep it in the platform keychain (macOS Keychain, Windows DPAPI).
  • Monitor the real clipboard. Replace simulate-clipboard with NSPasteboard change-count polling on macOS, Win32 clipboard notifications on Windows, or X11 or Wayland events on Linux.
  • Enforce retention. get-config reports 30 days, and cleanup_old_events exists in src/db.rs, but nothing calls it. Run it on a timer.
  • Batch and aggregate events. A busy folder produces many modify events. Batch inserts and aggregate repeated events.
  • Use a real PII detector. The regexes are a demo. Use a dedicated detection library or model.

Adapt it

Prompt
Use the alien endpoint-agent example (github.com/alienplatform/alien/tree/main/examples/endpoint-agent) as a model. Build a Rust Daemon for my product that runs on customer machines, keeps its data in an encrypted local database, opens no ports, and answers my backend only through commands registered on alien_commands::Receiver. Enable commands with .commandsEnabled(true) and call them from my backend with CommandsClient.forDeployment. Read https://alien.dev/docs/guide/commands.md and https://alien.dev/docs/infrastructure/daemon.md first.

Next

On this page