Local execution gateway for cloud AI. Lets cloud-side orchestrators run tools on local machines behind NAT/firewalls via WebSocket, with strong typing (protobuf), local policy enforcement, and browser automation.
Cloud (WS server) ←── WebSocket (protobuf) ──→ Local daemon (WS client)
│ │
@ahand/sdk ahandd
(control plane) (job executor)
├─ shell / tools
├─ browser automation
├─ file operations
├─ app tool registry
└─ policy enforcement
- Cloud hosts the WebSocket endpoint; local daemon connects outbound (no public IP needed).
- SDK (
@ahand/sdk) accepts upgraded WS connections and provides a typed Job API. - Daemon (
ahandd) enforces local security policy before executing any job. - Admin Panel — local web UI served by
ahandctl configurefor status, config, logs, and run history.
The production hub stack is the operator-facing control plane:
ahand-hubis the Rust API/WebSocket service that owns auth, device state, jobs, and audit logs.ahand-hub-dashboardis the Next.js 16 dashboard that talks to the hub over HTTP and the dashboard WebSocket.- PostgreSQL stores durable hub state.
- Redis backs transient queueing and presence data.
The deployment assets live under deploy/hub. That stack exposes two Docker build targets:
hubfor the Rust service imagedashboardfor the Next.js dashboard image
The dashboard can be deployed independently first. The compose stack runs the hub and dashboard against externally managed PostgreSQL and Redis endpoints.
curl -fsSL https://raw.githubusercontent.com/team9ai/aHand/main/scripts/dist/install.sh | bashThe installer verifies the SHA-256 checksum of the daemon, CLI, and admin-panel artifacts before installing (fail-closed: aborts if the checksum file is missing or mismatches). Gatekeeper quarantine (com.apple.quarantine) is removed automatically for the installed binaries. After installation the installer prints the exact shell profile line to add ~/.ahand/bin to your PATH — paste and run it, then open a new terminal.
curl -fsSL https://raw.githubusercontent.com/team9ai/aHand/main/scripts/dist/install.sh | bashThe installer verifies the SHA-256 checksum of the daemon, CLI, and admin-panel artifacts before installing (fail-closed). There is no Gatekeeper step. After installation the installer prints the exact shell profile line to add ~/.ahand/bin to your PATH — paste and run it, then open a new terminal.
Linux binary note: Released Linux binaries are glibc builds for
x86_64andaarch64. musl/Alpine andarmv7builds are not provided.
irm https://raw.githubusercontent.com/team9ai/aHand/main/scripts/dist/install.ps1 | iexRun in a regular (non-admin) PowerShell terminal. Requires Windows 10 1809 or later for full daemon PTY support (the installer itself requires Windows 10 1803+ for in-box tar). The installer verifies SHA-256 checksums when the checksum file is available, then automatically adds %USERPROFILE%\.ahand\bin to your user PATH. Open a new terminal after installation for the PATH change to take effect.
This installs ahandd, ahandctl, and the admin panel to ~/.ahand/ (macOS/Linux) or %USERPROFILE%\.ahand\ (Windows).
Environment variables (honoured on all platforms):
AHAND_VERSION— install a specific version (default: latest)AHAND_DIR— install directory (default:~/.ahand/%USERPROFILE%\.ahand)
ahandctl configure # open admin panel in browser to set up configThe admin panel guides you through initial setup (connection mode, gateway host, etc.) and writes ~/.ahand/config.toml.
ahandctl start # start daemon in background
ahandctl status # check if daemon is running
ahandctl stop # stop the daemon
ahandctl restart # restart the daemonLogs are written to ~/.ahand/data/daemon.log (macOS/Linux) or %USERPROFILE%\.ahand\data\daemon.log (Windows).
ahandctl upgrade is implemented natively in Rust and works on all platforms (macOS, Linux, Windows) — no shell required.
ahandctl upgrade # upgrade to latest
ahandctl upgrade --check # check for updates without installing--check queries the latest release and prints current vs. available versions without downloading or installing anything.
Note: upgrade.sh (in scripts/dist/) is deprecated and kept only for legacy installs that pre-date native upgrade support.
ahandctl browser-init # install browser automation dependenciesThis sets up playwright-cli and a local Node.js runtime on all three platforms — no shell or bash required.
Platform behaviour:
- macOS / Linux — downloads the
.tar.xzNode.js distribution and extracts it to the managed runtime directory (~/.cache/ahand-runtimes/ahand-primary-runtime/). - Windows — downloads the official
.zipNode.js distribution from nodejs.org, extracts it with traversal/symlink guards, and normalises the flat zip layout (node.exeat archive root) into the consistentbin/node.exeshape used on all platforms. npm and playwright-cli are invoked vianode.exe <js-entrypoint>(thenpm-cli.jspath and the@playwright/clipackage.json"bin"entry resolved at runtime) — no.cmdshim is spawned.
Browser requirement: Chrome or Edge must be installed on the machine. Both are auto-detected from the standard install paths; no additional browser download is needed.
setup-browser.sh is deprecated (kept for legacy installs only; no longer downloaded or installed during upgrade). Use ahandctl browser-init instead on all platforms.
The daemon enforces per-caller session modes:
| Mode | Behavior |
|---|---|
| Inactive | Default — rejects all jobs until activated |
| Strict | Every command requires manual approval |
| Trust | Auto-approve with inactivity timeout (default 60 min) |
| Auto-Accept | Auto-approve, no timeout |
The daemon exposes a 14-operation file API (read text/binary/image, write, edit, delete, stat, list, glob, mkdir, copy, move, create_symlink, chmod) gated by a [file_policy] block in ~/.ahand/config.toml:
[file_policy]
enabled = true
path_allowlist = ["~/projects/**", "/workspace/**"]
path_denylist = ["**/.git/**", "**/node_modules/**"]
dangerous_paths = ["~/.ssh/**", "/etc/passwd"]
max_read_bytes = 104857600 # 100 MB
max_write_bytes = 10485760 # 10 MBdangerous_paths matches escalate to STRICT-mode approval. Allowlist patterns support ~ expansion (fail-loud if HOME is unavailable) and glob (**, ?). The hub forwards via POST /api/devices/{device_id}/files with admission control. See proto/ahand/v1/file_ops.proto for the wire format.
Host applications that embed ahandd can register application-defined tools at runtime. Cloud callers discover and invoke them through the hub without custom protocol work.
// in the host application that embeds ahandd
use ahandd::{AppToolDef, AppToolError, AppToolHandler};
use serde_json::{Value, json};
use std::sync::Arc;
let def = AppToolDef {
name: "run_analysis".to_string(),
description: "Run a data analysis job".to_string(),
input_schema: json!({
"type": "object",
"properties": { "dataset": { "type": "string" } }
}),
requires_approval: false,
};
let handler: AppToolHandler = Arc::new(|_args: Value| {
Box::pin(async move {
// ... handler logic ...
Ok(json!({"status": "done"}))
})
});
daemon_handle.register_app_tool(def, handler).await?;// cloud caller via SDK
const catalog = await client.listAppTools(deviceId);
const result = await client.invokeAppTool(deviceId, "run_analysis", { dataset: "q1" });Session-mode gating applies to every invocation. requires_approval: true on a descriptor forces explicit approval regardless of the caller's session mode.
Approval + timeout composition: the approval wait on the daemon side is bounded by
min(approval policy timeout, clamp(timeoutMs))— the request's clampedtimeoutMscaps the user's time to answer the dialog. A late approval finds the request already expired and nothing executes. SettimeoutMs≥ expected approval latency (max 300 000 ms); the request timeout is now the hard deadline for both approval and execution.
See proto/ahand/v1/app_tool.proto and docs/remote-control-roadmap.md (Section 5) for the full capability description.
ahand/
├─ proto/ahand/v1/ # Protobuf definitions (single source of truth)
│ ├─ envelope.proto # core protocol messages
│ ├─ browser.proto # browser automation messages
│ └─ file_ops.proto # file operation messages (read/write/edit/delete/list/glob/copy/move/symlink/chmod)
├─ packages/
│ ├─ proto-ts/ # @ahand/proto — ts-proto generated types
│ └─ sdk/ # @ahand/sdk — cloud control plane SDK
├─ apps/
│ ├─ admin/ # Admin panel (Solid.js SPA)
│ ├─ hub-dashboard/ # Production Control Center dashboard (Next.js)
│ ├─ dashboard/ # Dashboard UI (dev mode)
│ └─ dev-cloud/ # Development cloud server (WS + dashboard)
├─ crates/
│ ├─ ahand-protocol/ # Rust prost generated types
│ ├─ ahand-hub/ # Production control plane HTTP/WebSocket server
│ ├─ ahand-hub-core/ # Hub domain logic
│ ├─ ahand-hub-store/ # Hub persistence adapters
│ ├─ ahand-platform/ # Cross-platform OS abstraction (paths/IPC/process/shell/signals/secure-file)
│ ├─ ahandd/ # Local daemon (bin)
│ └─ ahandctl/ # CLI tool (bin)
├─ deploy/
│ └─ hub/ # Hub Dockerfile + compose stack
├─ scripts/
│ └─ dist/ # Distribution scripts (install, upgrade, setup-browser)
├─ e2e/scripts/ # E2E tests for distribution scripts (BATS)
├─ .github/workflows/ # CI/CD (client-ci, hub-ci, release-rust, release-admin, release-browser, release-hub)
├─ turbo.json # Turborepo pipeline
├─ Cargo.toml # Rust workspace
└─ pnpm-workspace.yaml # pnpm monorepo
- Node.js >= 20, pnpm >= 10
- Rust (edition 2024)
- protoc (Protocol Buffers compiler)
pnpm install # install TS dependencies
pnpm build # build all TS packages (turbo)
cargo check # check Rust workspacepnpm dev # start dashboard + dev-cloud + daemon
pnpm dev:admin # admin panel only
pnpm dev:cloud # cloud server + dashboard
pnpm dev:daemon # daemon only (watch mode)
pnpm dev:hub-dashboard # production dashboard onlypnpm test # all tests
pnpm test:ts # TypeScript tests
pnpm test:rust # Rust tests
pnpm test:hub-dashboard # hub dashboard tests
pnpm test:e2e:scripts # distribution script tests (BATS)
# Persistent store roundtrip against disposable Postgres + Redis
cargo test -p ahand-hub-store --features test-support --test store_roundtripbash scripts/release.sh # local release build to release/Per-component versioning via git tags:
git tag rust-v0.2.0 && git push origin rust-v0.2.0 # daemon + CLI
git tag admin-v0.2.0 && git push origin admin-v0.2.0 # admin panel
git tag browser-v0.2.0 && git push origin browser-v0.2.0 # browser bundle
git tag hub-v0.2.0 && git push origin hub-v0.2.0 # production control center imagesEach tag triggers a GitHub Actions workflow that builds and publishes the relevant release artifacts. The hub release workflow pushes the ahand-hub and ahand-hub-dashboard images to GitHub Container Registry.
release-hub.yml is tag-provenance preserving by default:
git push origin hub-vX.Y.Zbuilds from that pushed tagworkflow_dispatchis only for rebuilding an existinghub-v*tag, not for publishing an arbitrary branch under a release tag
To validate the hub stack against external PostgreSQL and Redis endpoints:
export AHAND_HUB_SERVICE_TOKEN=dev-service-token
export AHAND_HUB_DASHBOARD_PASSWORD=dev-dashboard-password
export AHAND_HUB_DEVICE_BOOTSTRAP_TOKEN=dev-bootstrap-token
export AHAND_HUB_DEVICE_BOOTSTRAP_DEVICE_ID=device-dev-1
export AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS=http://127.0.0.1:3100
export AHAND_HUB_JWT_SECRET=dev-jwt-secret
export AHAND_HUB_DATABASE_URL=postgres://ahand_hub:secret@db.example.internal:5432/ahand_hub
export AHAND_HUB_REDIS_URL=redis://cache.example.internal:6379
export AHAND_HUB_AUDIT_FALLBACK_PATH=/var/lib/ahand-hub/audit-fallback.jsonl
docker compose -f deploy/hub/docker-compose.yml up --buildThe compose file starts the hub and dashboard containers only. PostgreSQL and Redis remain external dependencies that must already be reachable at the configured URLs.
If the dashboard is served from a different browser origin than the hub, set AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS on the hub to the public dashboard origin list.
For a local smoke environment, build local images, provision disposable external dependencies, wait for them to accept connections, and then run the hub and dashboard containers on the same network:
docker build -t ahand-hub:local --target hub -f deploy/hub/Dockerfile .
docker build -t ahand-hub-dashboard:local --target dashboard -f deploy/hub/Dockerfile .
docker network create ahand-hub-smoke
docker run -d --rm --network ahand-hub-smoke --name ahand-hub-postgres \
-e POSTGRES_DB=ahand_hub \
-e POSTGRES_USER=ahand_hub \
-e POSTGRES_PASSWORD=ahand_hub \
postgres:16-alpine
docker run -d --rm --network ahand-hub-smoke --name ahand-hub-redis redis:7-alpine
until docker exec ahand-hub-postgres pg_isready -U ahand_hub -d ahand_hub; do sleep 1; done
until docker exec ahand-hub-redis redis-cli ping; do sleep 1; done
docker run -d --rm --network ahand-hub-smoke --name ahand-hub \
-p 18080:8080 \
-e AHAND_HUB_BIND_ADDR=0.0.0.0:8080 \
-e AHAND_HUB_SERVICE_TOKEN=dev-service-token \
-e AHAND_HUB_DASHBOARD_PASSWORD=dev-dashboard-password \
-e AHAND_HUB_DEVICE_BOOTSTRAP_TOKEN=dev-bootstrap-token \
-e AHAND_HUB_DEVICE_BOOTSTRAP_DEVICE_ID=device-dev-1 \
-e AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS=http://127.0.0.1:13100 \
-e AHAND_HUB_JWT_SECRET=dev-jwt-secret \
-e AHAND_HUB_DATABASE_URL=postgres://ahand_hub:ahand_hub@ahand-hub-postgres:5432/ahand_hub \
-e AHAND_HUB_REDIS_URL=redis://ahand-hub-redis:6379 \
-e AHAND_HUB_AUDIT_FALLBACK_PATH=/var/lib/ahand-hub/audit-fallback.jsonl \
-v ahand-hub-audit:/var/lib/ahand-hub \
ahand-hub:local
docker run -d --rm --network ahand-hub-smoke --name ahand-hub-dashboard \
-p 13100:3100 \
-e AHAND_HUB_BASE_URL=http://ahand-hub:8080 \
ahand-hub-dashboard:local
curl -fsS http://127.0.0.1:18080/api/health
curl -fsS http://127.0.0.1:13100/login >/dev/null
curl -fsS \
-c /tmp/ahand-hub-dashboard.cookies \
-H 'content-type: application/json' \
-d '{"password":"dev-dashboard-password"}' \
http://127.0.0.1:13100/api/auth/login >/tmp/ahand-hub-dashboard-login.json
TOKEN=$(awk '$6 == "ahand_hub_session" { print $7 }' /tmp/ahand-hub-dashboard.cookies)
if [ -z "$TOKEN" ]; then
cat /tmp/ahand-hub-dashboard-login.json >&2
exit 1
fi
curl -fsS \
-b /tmp/ahand-hub-dashboard.cookies \
http://127.0.0.1:13100/api/proxy/api/auth/verify
DASHBOARD_SESSION="$TOKEN" node --input-type=module <<'EOF'
import http from "node:http";
const request = http.request("http://127.0.0.1:13100/ws/dashboard", {
headers: {
Connection: "Upgrade",
Upgrade: "websocket",
"Sec-WebSocket-Version": "13",
"Sec-WebSocket-Key": "YWhhbmQtaHViLXNtb2tlIQ==",
Origin: "http://127.0.0.1:13100",
Cookie: `ahand_hub_session=${process.env.DASHBOARD_SESSION ?? ""}`,
},
});
const timeout = setTimeout(() => {
console.error("dashboard websocket handshake timed out");
request.destroy(new Error("dashboard websocket timeout"));
}, 5_000);
request.on("upgrade", (_response, socket) => {
clearTimeout(timeout);
socket.destroy();
process.exit(0);
});
request.on("response", (response) => {
clearTimeout(timeout);
console.error(`unexpected dashboard websocket response: ${response.statusCode}`);
process.exit(1);
});
request.on("error", (error) => {
clearTimeout(timeout);
console.error(error.message);
process.exit(1);
});
request.end();
EOFApache-2.0