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).
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
| Part | File | Behavior |
|---|---|---|
| File watcher | src/monitor.rs | Records file_created, file_modified, and file_removed events for the monitored paths |
| PII scanner | src/pii.rs | Matches emails, SSNs, 16-digit card numbers, phone numbers, and keywords such as password and token |
| Encrypted store | src/db.rs | Keeps events in a local Turso database encrypted with AEGIS-256 |
| Command handlers | src/commands.rs | get-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:
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
localplatform runs the Daemon as a supervised process on the machine it's deployed to. - The
executionprofile grants no cloud permissions, because the agent touches nothing outside the device. - The
eventsStorage 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:
// 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:
// 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:
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.
| Command | Parameters | Returns |
|---|---|---|
get-events | since: a duration such as 30s, 5m, 1h, 2d. limit: optional, default 100 | { "events": [{ "id", "eventType", "data", "timestamp" }] }, newest first |
get-config | none | { "monitoredPaths": [...], "clipboardMonitoring": true, "eventRetentionDays": 30 } |
scan-path | path: a directory | { "filesScanned": n, "sensitiveFiles": [{ "path", "reason" }] } |
simulate-clipboard | content: 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:
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:
// 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:
// 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=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefIn 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, setsMONITORED_PATHSandDB_ENCRYPTION_KEY, and calls each command withdeployment.invokeCommand.
cargo test -p endpoint-agent
pnpm testCall it from your backend
Your backend calls the same commands with @alienplatform/commands. .target("agent") names the resource explicitly:
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-1For 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_KEYto 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-clipboardwithNSPasteboardchange-count polling on macOS, Win32 clipboard notifications on Windows, or X11 or Wayland events on Linux. - Enforce retention.
get-configreports 30 days, andcleanup_old_eventsexists insrc/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.