
A full-stack deployment and operations CLI for self-hosted OpenClaw AI assistants, built with MCP safety controls from the ground up. It provisions infrastructure on AWS, GCP, Azure, or bare Linux VMs using the embedded Pulumi Automation API, then exposes every day-to-day operation (status, logs, backups, config updates, monitoring) as typed MCP tools with read-only mode, destructive action confirmation, and audit logs. The interactive setup wizard gets you from zero to a running OpenClaw instance in about two minutes, including LLM provider config and chat integrations. Version 1.5.0 adds the ability to wire the OpenClaw gateway's own AI as an MCP client of clawops, so in-conversation commands like "check if my stack is healthy" invoke the real CLI rather than guessing.
MCP-native infrastructure ops for OpenClaw, with read-only mode, destructive-action confirmation, and audit logs built in.
clawops is a CLI and MCP server for deploying and operating self-hosted OpenClaw instances. Provision on AWS, GCP, Azure, or any Linux VM, then manage day-to-day operations from the terminal, or let Claude Code and Cursor drive them through typed MCP tools with explicit safety controls.
What's new: 2.0.1 and 2.0 release notes below, and the full history in CHANGELOG.md.
~/.clawops/.pulumi-cli on first use.--json) for scripting and automation.docs/limitations.md for the manual path.~/.clawops/config.json.docs/support-matrix.md.npm install -g @clawops/cli
clawops setup
clawops setup is an interactive wizard that gets OpenClaw running in about 2 minutes. It
handles everything in one flow, no config files to write by hand, no commands to memorize.
Step 1. Choose a deployment target
Pick an existing server you can SSH into (Linux or macOS), or a new cloud VM on AWS, GCP, or Azure. Cloud deployments walk you through authenticating with the provider CLI if you aren't already signed in.
Step 2. Pick an LLM provider
Choose from Anthropic, OpenAI, Amazon Bedrock, Ollama, or others. The wizard prompts for your
API key and saves it locally (in ~/.clawops/secrets/, chmod 600), it is never sent anywhere
except to OpenClaw on the target host when the config is applied.
Step 3. Add chat integrations (optional)
Select any combination of Discord, Telegram, Slack, WhatsApp, or Teams. The wizard collects each integration's bot token the same way as the API key. Paste it in, reference an env var, or point to a file.
Step 4. Wire your AI editor
Select which AI apps should have access to clawops. Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, and Zed are all supported. The wizard writes an MCP server entry into each app's config file using the absolute binary path so the app can launch it independently.
Step 5. Deploy
The wizard bootstraps OpenClaw on the target host over SSH (installs Docker, pulls the image, starts the container), applies your LLM and integration config, generates a gateway auth token, and prints a direct dashboard URL:
✔ All done! OpenClaw is running.
ℹ Open dashboard: http://192.168.1.50:18789?token=<your-token>
ℹ Token saved to ~/.clawops/secrets/GATEWAY_TOKEN_my-stack
Prerequisites: Node.js ≥ 22, an SSH key, and either an SSH-reachable Linux/macOS host or a
cloud account with CLI credentials configured (aws configure, gcloud auth login, or az login).
You do not need Pulumi. The first cloud deployment installs the CLI clawops drives into
~/.clawops/.pulumi-cli and says so while it does.
For a full narrated walkthrough with example output, see docs/demo-script.md.
If you prefer step-by-step control, or are adding clawops to an already-running deployment:
npm install -g @clawops/cli
clawops doctor # verify environment
clawops init --provider local --host 192.168.1.50 --user ubuntu --key-path ~/.ssh/id_ed25519
clawops up # installs Docker + OpenClaw over SSH
clawops status
See docs/examples/local-vm.md for SSH prerequisites, firewall
setup, and troubleshooting.
npm install -g @clawops/cli
# Requires AWS credentials in your environment (AWS_PROFILE or ~/.aws/credentials)
clawops init --provider aws
# Edit ~/.clawops/config.json: set stateUrl to your S3 bucket
clawops plan --provider aws --stack default --ssh-cidr auto --out /tmp/plan.json
clawops apply /tmp/plan.json
--ssh-cidr auto allows SSH from this machine's public IP, resolved while the plan is
generated and written into it. Without it the plan allows no ingress at all and nothing,
including clawops, will be able to connect.
The setup wizard handles this automatically (Step 4). To wire or re-wire editors at any time:
clawops mcp install
This opens the same interactive checkbox used in the wizard, select Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, or Zed and clawops writes the MCP entry into each app's config using the correct absolute binary path.
To add the entry manually instead, paste this into your editor's MCP config:
{
"mcpServers": {
"clawops": {
"command": "npx",
"args": ["-y", "@clawops/cli", "mcp", "serve", "--read-only"]
}
}
}
That form needs nothing on $PATH and is what a directory or an installer will copy. If you
would rather point at the binary you already have, use its absolute path — the output of
which clawops — with the same arguments:
{
"mcpServers": {
"clawops": {
"command": "/path/to/clawops",
"args": ["mcp", "serve", "--read-only"]
}
}
}
Either way, pass the arguments. mcp serve is what speaks the protocol, and an explicit config
is one that still reads clearly a year later. clawops does not strand a client that omits them:
run with no command at all and a pipe on stdin — how every MCP client starts a server — and it
starts mcp serve, saying so on stderr. Typed at a terminal, clawops still prints help.
Pass cloud credentials in env. A client starts the server with the environment its config
gives it, not your shell's, so an AWS_PROFILE exported in a terminal does not reach it, and
every tool that reads a stack fails with "Could not read the state of stack". Name the profile,
or the variables your state backend needs, in the server's env block:
{
"mcpServers": {
"clawops": {
"command": "npx",
"args": ["-y", "@clawops/cli", "mcp", "serve"],
"env": { "AWS_PROFILE": "my-profile" }
}
}
}
It holds a profile name, not a key: the credentials themselves stay where the cloud CLI keeps them (R6).
Config file locations:
| App | Path |
|---|---|
| Claude Desktop (macOS) | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Claude Desktop (Linux) | ~/.config/Claude/claude_desktop_config.json |
| Claude Code | ~/.claude.json |
| Cursor | ~/.cursor/mcp.json |
| Windsurf | ~/.codeium/windsurf/mcp_config.json |
| VS Code (macOS) | ~/Library/Application Support/Code/User/mcp.json |
| VS Code (Linux) | ~/.config/Code/User/mcp.json |
| Zed | ~/.config/zed/settings.json (key: context_servers) |
Start with --read-only. It enables status, logs, config reads, and diagnostics while
blocking mutations. Remove it only after reviewing
docs/security/mcp-safety.md.
Destructive tools (clawops_destroy, clawops_up, clawops_config_set, etc.) require explicit
confirmation before executing, they will never run silently.
For HTTP mode setup see docs/mcp/.
clawops status # Stack outputs: IP, gateway URL, SSH info
clawops logs -f # Tail OpenClaw logs over SSH
clawops ssh # Interactive SSH session
clawops ssh --command "docker ps"
clawops config get maxAgents
clawops config set maxAgents 8
clawops tunnel # Port-forward gateway UI to localhost
clawops destroy --yes # Destroy cloud-provider stack
clawops down --yes # Destroy local-provider stack
| Command | Description |
|---|---|
setup | First-run wizard: guided LLM, integrations, and deploy-plan generation |
init | Register a stack in ~/.clawops/config.json without provisioning. Additive, existing stacks are kept; --force is needed only to overwrite one |
up | Provision or update stack (--dry-run for preview, --gateway-port for a non-default port) |
down | Destroy local-provider stack (requires --yes; --dry-run shows current outputs) |
destroy | Destroy cloud-provider stack with confirmation prompt (--dry-run shows current outputs) |
status | Show stack outputs: IP, gateway URL, region, provisioned time |
plan | Generate a deploy-plan JSON artifact (dry-run safe). --ssh-cidr <list|auto> and --gateway-cidr decide who may connect; --publish-gateway loopback|all decides what is listening; --private-only closes public access on a stack reached over its tailnet |
apply | Apply a previously reviewed plan file (--dry-run validates and shows diff without applying) |
ssh | Interactive SSH session or run a remote command |
logs | Stream OpenClaw logs (-f, --tail N, --since 5m) |
tunnel | Local port-forward to gateway UI over SSH |
config | Get/set remote OpenClaw config values (--dry-run shows would-write JSON) |
agents | List OpenClaw agents, or stream one agent's logs |
gateway | Restart the OpenClaw gateway service |
backup | Create and restore OpenClaw state backups (restore expands into a staging directory, never in place; --activate swaps it in and rolls back if the gateway does not come up) |
stacks | List named stacks and their state |
doctor | Check the local machine; with --stack, the deployment's health too; with --provider, one cloud's credentials and account setup whether or not a stack exists; with --instance-type, account checks ask about that size rather than the provider default. --json for the report. Exits 1 on any failure |
secret | Manage secrets: list, set, delete, rotate, audit |
monitor | Live dashboard: gateway health, container stats, log tail, stack picker |
mcp serve | Start the embedded MCP server (stdio, or HTTP with --http <port> --token <t>) |
mcp install | Interactively wire clawops into AI editors |
mcp wire | Wire the gateway's AI as an MCP client of clawops (verifies the connection before saving) |
help | List all commands and global flags |
harden | Apply security hardening to a deployed stack (SSH, UFW, fail2ban, unattended-upgrades, Docker socket; AWS: SG audit, SSM check, Flow Logs, GuardDuty). --tailscale joins the stack to your tailnet and moves clawops onto that address once it answers; --tailscale-revert undoes it |
bug | Open a pre-filled GitHub issue with system context from doctor |
Full flag reference: clawops <command> --help
For non-local providers, clawops enforces a review-before-apply discipline:
# 1. Generate a plan: runs `pulumi preview` internally, produces JSON
# --ssh-cidr decides who may connect. `auto` means this machine; omit it and nobody can.
clawops plan --provider aws --region us-east-1 --ssh-cidr auto --out /tmp/plan.json
# 2. Review plan.json: the `diff` field shows projected changes at plan-generation time
cat /tmp/plan.json | jq .diff
# 3. Apply: reads and validates the plan file, then runs `pulumi up`
clawops apply /tmp/plan.json
# Without --yes, apply prompts: "Continue? (y/N)"
clawops apply /tmp/plan.json --yes # skip prompt in automation
The plan JSON conforms to spec/deploy-plan.schema.json (AJV-validated) and captures reviewed
intent: provider, region, instance type, CIDR ranges, and OpenClaw version. apply re-runs
pulumi up using those parameters against the current live state, it does not replay a locked
execution artifact. Review and apply in the same session to minimize drift risk.
See docs/plan-apply.md for full semantics, drift guidance, and the safe CI pattern.
clawops ships an embedded MCP server. Claude Code, Cursor, and any MCP-compatible agent can drive deployments without leaving the chat interface.
clawops mcp install # interactive checkbox: writes config for selected apps
The wizard resolves the absolute binary path automatically so app launchers can find clawops
without inheriting your shell's PATH. See Connect an AI editor above
for manual config paths.
The OpenClaw gateway runs its own AI agent. Once wired, that agent can call clawops directly instead of guessing at infrastructure state:
clawops mcp wire --stack prod # write MCP client entry into gateway config + restart
Requires OpenClaw ≥ 2026.4 on the gateway. The clawops setup wizard offers this step
automatically after a successful deploy.
Start the server manually or confirm your config is correct:
clawops mcp serve --read-only # safe for first evaluation
clawops mcp serve # full mode: enables provisioning, config write, ssh exec
clawops mcp serve --http 3333 --bind 127.0.0.1
# MCP HTTP server listening on 127.0.0.1:3333
Do not bind to a non-loopback address without additional authentication controls in front of it.
| Tool | Toolset | Description |
|---|---|---|
clawops_status | cli | Show stack outputs (what is deployed, not whether it works) |
clawops_doctor | cli | Run diagnostics: local prerequisites, and with a stack, remote health |
clawops_logs_tail | cli | Tail OpenClaw logs |
clawops_monitor | cli | Sample gateway and host metrics |
clawops_stacks_list | admin | List all stacks and their state |
clawops_config_get | cli | Read a remote config value |
clawops_agents_list | cli | List running agents |
clawops_agents_logs | cli | One agent's recent activity, paginated |
clawops_gateway_status | cli | Whether the gateway container is running, and its image |
clawops_secret_list | cli | List stored secrets by name and status (never values) |
clawops_secret_audit | cli | Report missing secrets and unresolvable $secret: references |
clawops_up | cli | Provision or update a stack |
clawops_destroy | cli | Destroy a stack (elicits confirmation) |
clawops_apply | cli | Apply a plan file |
clawops_plan | cli | Generate a deploy plan |
clawops_config_set | cli | Write a remote config value |
clawops_config_unset | cli | Remove a remote config key |
clawops_config_validate | cli | Validate the deployed config against the OpenClaw schema |
clawops_gateway_restart | cli | Restart the gateway (elicits confirmation) |
clawops_gateway_update | cli | Move the gateway to a concrete OpenClaw version (elicits confirmation) |
clawops_backup_create | cli | Back up gateway state to a local archive |
clawops_backup_restore | cli | Restore a backup to staging, or with activate into service (elicits confirmation) |
clawops_migrate | cli | Migrate a 1.x deployment onto the 2.0 runtime (elicits confirmation) |
clawops_stacks_delete | admin | Forget a stack in config; does not destroy infrastructure (elicits confirmation) |
clawops_secret_delete | cli | Delete a stored secret (elicits confirmation) |
clawops_harden | cli | Apply hardening modules; join or leave a tailnet (elicits confirmation) |
clawops_init | cli | Register a stack and write ~/.clawops/config.json (no cloud resources) |
clawops_workflow_deploy_app | workflow | End-to-end deploy: plan → confirm → apply → status |
clawops_workflow_recover | workflow | Diagnostic workflow for an unhealthy stack |
clawops_task_status | cli | Poll a long-running task |
Tools in the read toolset are also available in --read-only mode; the table's Toolset column shows the primary toolset. All other toolsets require full mode.
Destructive tools require explicit confirmation (elicitation) unless yes: true is passed.
See docs/security/tool-risk-matrix.md for the full risk
classification of every tool.
Config lives at ~/.clawops/config.json (override with $CLAWOPS_HOME).
{
"version": 1,
"defaults": {
"provider": "aws",
"stack": "default"
},
"stacks": {
"default": {
"provider": "aws",
"region": "us-east-1",
"stateUrl": "s3://my-clawops-state"
}
},
"ssh": {
"keyPath": "~/.clawops/id_ed25519",
"knownHostsPath": "~/.clawops/known_hosts"
}
}
Cloud credentials are never stored in config. Clawops reads them from the environment:
| Provider | Credential source |
|---|---|
| AWS | AWS_PROFILE or standard AWS credential chain (~/.aws/credentials) |
| GCP | GOOGLE_APPLICATION_CREDENTIALS or gcloud auth application-default login |
| Azure | AZURE_CLIENT_ID / AZURE_CLIENT_SECRET or az login |
| Local | SSH host + key configured in stacks[name].localOpts |
See docs/limitations.md for the full list. Key points:
clawops apply is not an immutable plan execution. See docs/plan-apply.md.--read-only for first evaluation.clawops
├── src/cli/ citty-based commands (one file per verb)
├── src/config/ ~/.clawops/config.json management
├── src/providers/ Cloud adapters (AWS, GCP, Azure, local)
│ ├── aws/ Pulumi inline program + ProviderAdapter
│ ├── gcp/
│ ├── azure/
│ └── local/ SSH bootstrap (no Pulumi)
├── src/pulumi/ Pulumi Automation API wrapper + output helpers
├── src/transport/ SSH client (ssh2) + connection pool + tunnels
├── src/mcp/ MCP server, tool handlers, progress tracking
├── src/plan/ Maker plan generation, AJV validation, apply
├── src/output/ ASCII table, spinner, JSON, human-readable output
├── src/errors/ Typed error hierarchy with exit codes
└── spec/ Machine-readable ground truth (JSON Schema, YAML)
Key design decisions:
~/.clawops/.pulumi-cli, pinned to the bundled SDK, without editing $PATH (ADR 0010); Pulumi home is sandboxed to ~/.clawops/.pulumi; stack programs are inline TypeScript closuresgs://), S3 (s3://), Azure Blob, no local state files, no pulumi.yamlssh2: never shells out to /usr/bin/ssh; TOFU host verification against ~/.clawops/known_hosts; connection pool with 5-min idle TTLgeneratePlan() → review → applyPlan(); destructive changes always require human review of the plan JSONspec/mcp-tools.yaml; all destructive tools use elicitationSee docs/architecture.md for a full narrative, and docs/decisions/ for ADRs.
Each cloud provider is an inline Pulumi program that creates the resources below. All three share the same outputs (publicIp, gatewayUrl, sshHost, sshPort, sshUser) consumed by the SSH and config-overlay layers.
flowchart LR
subgraph NET["Networking"]
VPC["VPC (10.0.0.0/16)"]
IGW[Internet Gateway]
SUBNET["Subnet (10.0.1.0/24)"]
RT[Route Table]
SG["Security Group (ports 22, 18789)"]
end
subgraph IAM["IAM"]
ROLE[IAM Role]
SSM[SSM Policy Attachment]
BED["Bedrock Policy Attachment (optional)"]
IP[Instance Profile]
end
subgraph COMPUTE["Compute"]
KP[EC2 Key Pair]
EC2["EC2 Instance (Ubuntu 22.04, IMDSv2)"]
EIP[Elastic IP]
end
flowchart LR
subgraph NET["Networking"]
NW[VPC Network]
SN["Subnetwork (10.0.0.0/24)"]
FW1["Firewall: SSH port 22 (conditional)"]
FW2["Firewall: Gateway port 18789 (conditional)"]
ADDR[Static External IP]
end
subgraph COMPUTE["Compute"]
VM["Compute Instance (Debian 12, 20 GB)"]
end
flowchart LR
RG[Resource Group]
subgraph NET["Networking"]
VNET["Virtual Network (10.0.0.0/16)"]
SUBNET["Subnet (10.0.1.0/24)"]
NSG["Network Security Group (ports 22, 18789)"]
PIP["Public IP Address (Static)"]
NIC[Network Interface]
end
subgraph COMPUTE["Compute"]
VM["VM (Ubuntu 22.04, managed identity)"]
end
subgraph KV["Key Vault (optional)"]
VAULT["Key Vault (RBAC, name max 24 chars)"]
RA["Role Assignment (Secrets User)"]
SECRET["Secret: gateway-token"]
end
git clone https://github.com/dfridkin/clawops.git
cd clawops
# Node 22+ required; use nvm: nvm use
pnpm install
pnpm dev doctor # verify toolchain
pnpm dev # run CLI from src/ via tsx
pnpm build # tsup → dist/
pnpm test # vitest (2090 tests, ~13s)
pnpm test:changed # vitest --changed (fast edit loop)
pnpm test:integration # Docker-based SSH integration tests
pnpm test:e2e:local # builds, then bootstrap, the host-touching MCP tools and a 1.x migration, in systemd containers
pnpm typecheck # tsc --noEmit
pnpm lint # eslint src/ tests/ scripts/ (--max-warnings=0)
pnpm gen:schemas # regenerate src/providers/types.ts + src/mcp/tools/_generated.ts
pnpm gen:schemas --check # CI guard: committed generated files match spec
pnpm graph # local coupling report (--base <ref> for this branch's delta)
pnpm verify:pack # install the packed tarball elsewhere and run it (CI gate)
pnpm sync:server-json # write package.json's version into server.json
pnpm changeset # record a release note before merging
| Path | Purpose |
|---|---|
spec/ | Machine-readable ground truth: JSON Schema, YAML. Treat as source of truth. |
SPEC.md | Full technical specification (milestones, rules, schemas) |
DESIGN_RULES.md | 25 normative rules (R1–R25) referenced throughout the codebase |
docs/architecture.md | Narrative system overview |
docs/plan-apply.md | Plan/apply semantics, drift guidance, CI pattern |
docs/ci.md | CI integration guide: OIDC, env vars, plan → apply in CI |
docs/security/ | MCP safety model, tool risk matrix, redaction, audit logs |
docs/providers/matrix.md | Per-provider capability matrix |
docs/decisions/ | Architecture Decision Records |
.claude/skills/ | Invokable procedures: /add-provider, /release, /tdd, /mcp-tool |
.claude/rules/ | Path-scoped lint rules loaded by Claude Code |
Two files are generated from spec/ and must not be hand-edited:
src/providers/types.ts. ProviderAdapter interface from spec/providers.schema.jsonsrc/mcp/tools/_generated.ts. Zod schemas and type exports from spec/mcp-tools.yamlRun pnpm gen:schemas after modifying either spec file. CI enforces this with --check.
Use the /add-provider skill in Claude Code, or follow src/providers/CLAUDE.md. Every adapter must satisfy ProviderAdapter in src/providers/types.ts. Do not relax the schema to fit the adapter.
Use the /mcp-tool skill. The skill adds the tool to spec/mcp-tools.yaml, runs pnpm gen:schemas, creates the handler in src/mcp/tools/<toolset>/<name>.ts, and wires it into the registry. All four annotation hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) are required on every tool.
feat(scope): description
fix(scope): description
docs / refactor / chore / test / perf / ci
Use pnpm changeset to record a release note before merging a feat or fix.
Recovery is a flag, agents can reach every command they need, and SSH failures say what failed.
clawops backup restore --file <archive> --activate swaps the restored state in, restarts
the gateway on it, and checks that it answers. Without --activate, a restore still
verifies and expands into a staging directory and touches nothing live..failed-restore-<timestamp>, as evidence./tmp, where the next gateway restart destroyed it.clawops backup --help no longer says restore is unavailable. It has worked since 2.0.activate), gateway
status and update, one agent's logs, migrate, stacks delete, and secret list, audit and
delete. Each calls the same code as its command, so both refuse the same things in the same
words.clawops ssh and clawops secret set / rotate stay CLI-only on purpose: a root shell or a
secret value does not belong in an agent's hands or its transcript. The secret tools never
return values.clawops doctor --stack <name> prints the same advice on its own line.The new tools were driven end to end over MCP against a systemd host, a 1.x deployment made by clawops 1.7.9, and AWS. That found defects the unit tests could not, several in the CLI too:
clawops gateway update works. Every update since the upgrade gate shipped refused before
touching anything, because the state snapshot was given a host path inside a container that
cannot create it.openclaw unit
with Restart=always, which undid a restart, an update, a migration or a restore about five
seconds after clawops made it. clawops now changes the gateway through the unit.clawops gateway, agents, config and logs work on local stacks. Each failed with "The
local provider does not use Pulumi stacks" while its MCP tool worked.clawops migrate refuses a stack already on 2.x, which it would have given a fresh minimal
config; asks before it runs, as --yes always claimed; and pulls the 2.x image before
stopping 1.x, so the downtime is a restart rather than a multi-gigabyte download.clawops secret delete ../config.json deleted the clawops config.clawops_gateway_restart keeps a gateway published on every interface where it was,
instead of moving it to loopback.code: -2, and names the
usual cause for an MCP server: credentials missing from its client config's env block.docker
group, a backup upload failed with "permission denied" after the backup had been taken.--activate instead of a manual procedure, and
CI follows every in-repo Markdown link and anchor (pnpm verify:docs).pnpm test:e2e:local, nightly and on PRs labelled e2e), and
E2E_MCP=1 pnpm test:cloud <cloud> backs up and restores over MCP on a real cloud host./llms.txt, a FAQ and a comparison page.Private networking, hardening on every cloud, and two fixes to commands that could not start. 2.1.1 follows with the fixes below it.
clawops harden --tailscale installs Tailscale on a stack, joins it to your tailnet as
clawops-<stack>, and reports the address it was given.clawops secret set TAILSCALE_AUTH_KEY, and reaches the host
over the SSH data channel. It never appears in a command line, a process list or a log.clawops plan --private-only → clawops apply closes public SSH and gateway access on a stack
reached over its tailnet. Both refuse unless that address answers SSH at that moment
(ADR 0013).clawops harden --tailscale-revert takes a host off the tailnet and returns clawops to its
public address. On a private-only stack it refuses, and prints the commands that reopen SSH.clawops destroy forgets the host keys for both addresses of a stack on its tailnet, instead
of leaving the public one pinned for an instance that no longer exists.clawops plan counts and lists resources that would be replaced. It used to summarise a
preview that would destroy the instance and its boot disk as "0 to create, 0 to update".clawops backup create; an update says the gateway goes down.clawops mcp serve could not start at all when installed from npm — it died on import before
emitting any protocol, so every MCP client got nothing. pnpm verify:pack now speaks MCP to
the packed tarball, so this class of failure cannot ship again.server.json, the MCP registry manifest, is versioned with the package rather than rewritten
at publish time. The committed file had read 1.7.3 against a published 2.0.2.clawops started with no command and a pipe on stdin serves MCP instead of printing help.
That is how every MCP client starts a server, and how directories that infer a run command
start one; several were getting the help text and reporting the server as broken. Typed at a
terminal, clawops still prints help, and so does clawops | less.npx -y @clawops/cli mcp serve, which runs as written. It used to
say /path/to/clawops, which nothing could run and no directory could copy.clawops harden says a stack is not deployed, or that its state could not be read, instead of
passing along code: -2 and a subprocess dump.npm pack --pack-destination
does not create its destination. pnpm verify:docker builds it and speaks MCP to the running
container, both through its entrypoint and as a bare binary, in CI.clawops_init registers a stack over MCP, so a client can bootstrap from nothing. It writes the
config and an SSH key and provisions nothing.yes: true, instead of getting a Client does not support form elicitation crash.ssh-keygen and a CA bundle; without them it could not make a key or
reach any HTTPS endpoint.A patch release, and a large one: in 2.0.0 no cloud deploy succeeded by any path. Every item
below is a fix or an addition in 2.0.1. The reasoning behind each one is in its commit message,
and the decisions that came out of them are in docs/decisions/.
clawops plan → clawops apply provisions a cloud stack and deploys OpenClaw onto it.clawops up deploys to AWS, GCP and Azure, running the same path as plan → apply.~/.clawops/.pulumi-cli, or uses a compatible
one already on $PATH (ADR 0010).clawops plan takes --ssh-cidr, --gateway-cidr and --publish-gateway, and apply
passes them to the cloud firewall. auto resolves this machine's address.clawops plan stops, and names the cause, when it cannot open the state backend.--instance-type takes a clawops alias (micro–gpu) or a machine type your cloud names
itself, and the plan records the concrete type.gcp:project on GCP,
azure-native:subscriptionId on Azure.clawops doctor --provider <cloud> checks one cloud's credentials and account setup, with or
without a stack. --instance-type points the size check at the size you are deploying.clawops setup runs the same checks and offers to fix what it safely can, enabling an API,
creating a state bucket with versioning on and public access blocked, naming the change
before making it.az login; a service principal is no longer required.clawops init keeps the stacks already in your config.clawops init generates an SSH key that clawops can read. If you ran init before this
release, clawops doctor will tell you whether yours is usable.gcloud config set project is honoured.bedrock:ListInferenceProfiles.apply waits for SSH, then waits for the gateway to answer, before reporting success.apply reports progress as it goes instead of going quiet for minutes.clawops logs reads from the gateway on AWS.doctor --stack, ssh, logs, gateway, config and agents work against a freshly
deployed stack.clawops doctor validates cloud credentials.clawops destroy forgets the instance's host key, so redeploying onto an address the cloud
has recycled no longer fails verification.pnpm test:cloud aws|gcp|azure runs it against a real
deployment and destroys it afterwards.clawops 2.x targets OpenClaw >= 2026.9.2. The 1.x line continues for OpenClaw
<= 2026.7.1-2 under the legacy dist-tag until 2027-03-31:
npm install -g @clawops/cli # 2.x
npm install -g @clawops/cli@legacy # 1.x maintenance
Pin the tag in CI. latest moves to 2.x, so an unpinned pipeline will change lines.
CHANGELOG.md carries the full history; this section covers what changed
about how clawops behaves.
OpenClaw 2.0 stores sessions, transcripts and credentials in SQLite. clawops mounted no
state at all, so every restart destroyed them, and a restart is what gateway restart,
gateway update and config set all do.
One host directory (/var/lib/clawops/openclaw) is now bind-mounted at OpenClaw's own
default location, holding the config, the database and any provider plugins. Existing
deployments migrate on the next up/apply.
clawops up / clawops applyflowchart TD
A["clawops plan"] --> B{"config valid<br/>against OpenClaw schema?"}
B -- no --> B1["refuse: plan is still<br/>a file you can edit"]
B -- yes --> C["clawops apply"]
C --> D{"OpenClaw version<br/>in supported range?"}
D -- no --> D1["refuse: names<br/>@clawops/cli@legacy"]
D -- yes --> E["provision host"]
E --> F["state dir, owned 1000:1000<br/>migrate any pre-2.0 config"]
F --> G["write config<br/>validated before writing"]
G --> H["install provider plugins<br/>while egress exists"]
H --> I["start gateway"]
I --> J{"/startupz says started?"}
J -- no --> J1["fail with the reason"]
J -- yes --> K{"configured providers<br/>all loaded?"}
K -- no --> K1["warn: healthy gateway,<br/>missing model backend"]
K -- yes --> L["done"]
Three of those steps are new, and each exists because the old flow could report success
while something was wrong: the config was never validated before being written, provider
plugins were left to be fetched at boot (or silently missing on a deny-all host), and
"started" was inferred from docker run exiting 0.
clawops gateway updatePreviously: pull, run, report success. docker run exiting 0 means the container was
created, and the container it replaced is already gone.
flowchart TD
A["clawops gateway update X"] --> B{"X in supported range?"}
B -- no --> B1["refuse before pulling"]
B -- yes --> C["docker pull X"]
C --> D["snapshot state database"]
D -- cannot snapshot --> D1["refuse: no rollback point"]
D --> E{"target release understands<br/>this schema?"}
E -- no --> E1["refuse: downgrade across<br/>a schema boundary"]
E -- yes --> F["swap container"]
F --> G{"/startupz says started?"}
G -- yes --> H["done"]
G -- no --> I["one-shot doctor --fix<br/>in a throwaway container"]
I --> J["re-run, re-gate"]
J -- started --> K["done: reported as repaired"]
J -- still not --> L["roll back to previous image"]
L -- started --> M["rolled back, reason reported"]
L -- still not --> N["failed: snapshot path named"]
The snapshot is not only a rollback point: database preflight refuses a live database
because the schema version sits in the WAL until checkpointed, so the consolidated snapshot
is what makes the compatibility check possible at all.
clawops gateway restartA restart changes neither the deployed version nor who can reach the gateway. Both are read back from the running container rather than guessed:
flowchart LR
A["gateway restart"] --> B["read current image"]
B -- no container --> B1["refuse: nothing to reuse.<br/>latest and stable point at 2.0"]
B --> C["read current publish scope"]
C --> D["recreate with the same<br/>version and reachability"]
D --> E{"/startupz says started?"}
E -- no --> E1["fail with the reason"]
E -- yes --> F["done"]
flowchart TD
A["clawops migrate"] --> B{"1.x container running?"}
B -- no --> B1["nothing to rescue: state was<br/>already lost to an earlier restart"]
B -- yes --> C["verified backup, inside the running container"]
C -- "backup fails" --> C1["refused: nothing touched"]
C --> D["extract state from the RUNNING container"]
D --> E["chown 1000:1000"]
E --> F["stop and remove 1.x"]
F --> G["synthesise a valid 2.0 config"]
G --> H["start 2.0 with the state directory"]
H --> I{"/startupz started?"}
I -- "no: schema still migrating" --> J["restart once"]
J --> K{"started?"}
K -- no --> K1["failed: points at the backup"]
K --> L["report"]
I -- yes --> L
L --> M["what carried over,<br/>device identity, config to review"]
Two things about that shape are not obvious, and both came from running a real migration:
State is extracted from the running container. All 1.x state lived inside it, clawops mounted none, so stopping first destroys what the migration came to save.
The config is synthesised, not carried forward. 1.x never had one that applied; the file clawops mounted was read by nothing. Your old settings are reported as intent to review, never applied blindly. Their channel blocks would not validate against 2.0 anyway.
The gateway also needs two starts: the first performs the state-schema migration and reports
it as pending. migrate waits for the second rather than declaring success early.
If you ran gateway restart, gateway update or config set on a clawops before 2.0, your
state is already gone, nothing was mounted to survive the container replacement. migrate
says so plainly rather than pretending to rescue it.
clawops backup restore works again, and never in placev1.7.5 made restore fail with an explanation, because the OpenClaw it supported had no restore subcommand to call. 2.0 does, and clawops delegates to it:
flowchart TD
A["clawops backup restore --file X"] --> B["upload archive to the host"]
B --> C["openclaw backup restore --target <staging>"]
C -- "target not empty" --> C1["refused by OpenClaw"]
C --> D["archive verified, expanded<br/>into a fresh directory"]
D --> E["warnings printed verbatim<br/>time travel, channel relink,<br/>approvals, plugins"]
E --> F["nothing activated"]
F --> G["you stop the gateway, swap the<br/>state dir, restart, re-apply"]
clawops does not extract archives itself and does not restore in place. The final step is
manual on purpose, and re-applying matters: the archive does not carry plugin
node_modules, so a restored deployment starts without its model providers, looking
healthy while doing it.
The archive is a credential. It carries the state database, mcp_oauth_stores,
secret_store_entries, worker_environment_credentials, device_auth_tokens, unencrypted.
clawops now writes it 0600 locally; it previously used the default 0644.
OpenClaw 2.0 made model providers install-gated plugins. Twenty-four ship in the image,
anthropic, openai, google, ollama, openrouter among them, but not all of them.
Configuring one that is not bundled, without installing it, produces a gateway that starts,
reports healthy, and has no model backend.
clawops installs what your config needs, pinned to an exact version, during apply:
Resolving clawhub:@openclaw/deepseek-provider@2026.9.2…
Downloading plugin @openclaw/deepseek-provider@2026.9.2 from ClawHub…
Installed plugin: deepseek
This adds an outbound dependency the 1.x line did not have: clawhub.ai. It is needed
while apply is running, not at boot. Deliberately, so a failure reaches the person running
the command rather than a locked-down host at 3am. Blocked, it looks like this:
fetch failed | getaddrinfo EAI_AGAIN clawhub.ai | EAI_AGAIN
clawops checks the installed provider IDs afterwards and will not call the deploy finished while a configured provider is missing. Required outbound access lists every destination and when it is needed.
Every channel in OpenClaw 2.0 is an install-gated plugin. clawops apply installs the ones
your config names, during the deploy while egress exists, and then asks the gateway whether
they are really installed:
[clawops] warning: the gateway is running, but these configured channels are not installed:
discord. They will never connect.
It has to ask. openclaw channels add. The obvious command, returns success even when the
plugin install fails, so clawops uses openclaw plugins install and verifies against
channels list --all --json.
Channel plugins are pinned to the supported runtime. The current latest does not install on
it: plugin "discord" requires plugin API >=2026.9.3, but this OpenClaw runtime exposes 2026.9.2. The same drift that forced version pins on model providers.
Telegram needs nothing installed: it ships in the image.
Config is validated against OpenClaw's own schema, captured from the image, not
hand-written, before anything is sent to the host, and again before a write replaces a
working file. clawops plan refuses a plan whose config the gateway would reject, while the
plan is still a file you can edit.
A rejected config is kept at <path>.rejected.<timestamp> and the live one is left alone, so
a validation failure never costs you what you were trying to write.
One rule is clawops's own: gateway.mode is optional in the schema and mandatory in
practice. A config without it passes openclaw config validate and then exits 78.
The gateway runs with --cap-drop=ALL, --security-opt no-new-privileges, --init and
--pids-limit 512. State is owned numerically by 1000:1000, matching the container's user
rather than a host account that may not have that uid.
doctor, plan, up and apply refuse an OpenClaw release outside the supported range, and
gateway restart reuses the version already deployed rather than resolving a moving tag. A
restart changes neither the version nor who can reach it.
The container publishes on 127.0.0.1:18789 instead of 0.0.0.0:18789. Reach it with
clawops tunnel or a reverse proxy on the host.
Previously the wizard set allowedGatewayCidrs from the CIDR you gave for SSH, so a
plaintext HTTP dashboard. Token in the URL. Was opened to your whole shell-access network
as a side effect of one unrelated answer. To bind all interfaces deliberately, set
network.publishGateway: "all".
You must act if a client or reverse proxy on another machine reaches the gateway
directly, or external monitoring hits /health. A proxy on the host is unaffected; one in a
container on the host needs --network host.
The gateway serves its Control UI on a catch-all route, so any unmatched path answers 200 with HTML:
/healthz 200 application/json {"ok":true,"status":"live"}
/health-typo 200 text/html <!doctype html>…
clawops probed with curl -fsS … >/dev/null, which succeeds on a typo. It proved something
was listening on the port, not that the gateway was healthy. Probes now read the response
body, and the restart gate uses /startupz rather than liveness, after a restart the
process listens long before startup finishes.
clawops mcp wire actually wires something nowIt has never worked, not on 2.0, not on any 1.x release. It wrote gateway.mcpClients,
which is not a key OpenClaw has: checked against the config schemas of 2026.4.5,
2026.7.1-2 and 2026.9.2. The real key is top-level mcp.servers. And the entry it wrote
was command: "clawops" over stdio, which spawns inside the gateway container, where
clawops is not installed and nothing installs it.
On 1.x nothing validated the write, so clawops stored a key nothing read, restarted your gateway, and reported: "The gateway's AI can now run clawops commands." It could not.
flowchart TD
A["clawops mcp wire"] --> B["openclaw mcp add --transport streamable-http"]
B --> C{"gateway connects<br/>to the URL?"}
C -- no --> C1["probe fails, nothing saved,<br/>clawops prints the reason"]
C -- yes --> D["saved to mcp.servers.clawops"]
D --> E["openclaw mcp reload"]
It delegates to openclaw mcp add now, which probes the server before saving, so
"wired" means the gateway connected, not that a file was written.
You have to run the server yourself. clawops is not installed on the gateway host:
clawops mcp serve --http 18790 --bind 0.0.0.0 --token "$(openssl rand -hex 16)"
clawops mcp wire --stack prod --token <same token>
Installing clawops on the gateway host is a deliberate follow-up, not part of 2.0: it puts
deployment credentials on the deployed box, and the gateway's AI is reachable from every
channel it is connected to. See docs/security/threat-model.md T11.
clawops mcp serve --http serves more than one client, and asks who you areTwo bugs, found by testing against a real gateway rather than a mock.
It built one transport for the whole process, so the first client to connect claimed it
and every later one. A second editor, a reconnect, the gateway's own probe, was answered
"Server already initialized". HTTP mode is the multi-client mode.
It had no authentication, while exposing every tool including clawops_destroy. It now
takes a bearer token, compares it in constant time, and refuses to bind anywhere but loopback
without one.
flowchart TD
A["clawops plan"] --> B{"publishGateway?"}
B -- "loopback (default)" --> C{"allowedGatewayCidrs empty?"}
C -- no --> C1["refuse: those rules would admit<br/>traffic to a closed port"]
C -- yes --> D["SSH rules only"]
B -- all --> E["SSH rules + gateway rules<br/>on spec.network.gatewayPort"]
D --> F["clawops harden"]
E --> F
F --> G["read the container's port bindings"]
G --> H{"published to the network?"}
H -- no --> H1["ufw: SSH only"]
H -- yes --> H2["ufw: SSH + the published port"]
Three security controls were doing the opposite of what they say.
clawops harden opened the gateway port on every deployment. The ufw module ran
ufw allow 18789/tcp unconditionally. Since the gateway publishes on 127.0.0.1, that
opened a port nothing was listening on. A hardening step widening the firewall past what the
deployment exposes. It now reads the running container's port bindings and adds the rule only
when the gateway is really published, on whatever port it is published on.
The AWS security-group audit exempted the two ports it exists to check. Ports 22 and
18789 were on an "expected" list, so a group opening SSH or the gateway to 0.0.0.0/0 came
back as "No unexpected open ingress rules found". It also never read IPv6 rules, so ::/0
was invisible.
The setup wizard defaulted SSH access to 0.0.0.0/0. Pressing Enter opened SSH to the
whole internet, on the path most first-time users take. It offers your own IP as a /32 now,
and when that cannot be detected it offers no default and requires an answer.
clawops plan could not express any of it, and apply never passed any of it to Pulumi.
Both are fixed in 2.0.1. See the list at the top of this section.
"network": {
"allowedSshCidrs": ["203.0.113.4/32"],
"allowedGatewayCidrs": [],
"publishGateway": "loopback",
"gatewayPort": 9443
}
One value now reaches the security-group rules, the container publish flag, the default
gateway.port and the gateway URL. It was a constant redeclared in eleven places, so
changing it meant finding all of them, and missing one produced a container publishing one
port, a gateway listening on another, and a firewall opening a third.
Local deployments use clawops up --gateway-port 9443.
clawops doctor answers whether it works, and says so in its exit codeflowchart TD
A["clawops doctor"] --> B["local: Node, Pulumi CLI + home,<br/>config, SSH key, credentials"]
B --> C{"--stack given?"}
C -- no --> Z["report"]
C -- yes --> D["container state"]
D --> E["deployed OpenClaw version"]
E --> F["probe /startupz<br/>and read the body"]
F --> G["published scope, disk,<br/>log rotation, hardening drift"]
G --> Z
Z --> Y{"any check failed?"}
Y -- no --> Y1["exit 0"]
Y -- yes --> Y2["exit 1"]
Three changes:
It asks the gateway. doctor used to read docker inspect's healthcheck field, which
the OpenClaw image does not set, so it reported "no healthcheck configured" and moved on. A
running container means the process started, not that it serves. It now probes /startupz
and reads the body.
It exits 1 when something failed. Only an old Node.js used to do that; an unreadable SSH
key or an unsupported gateway exited 0, so a CI step running clawops doctor read a broken
deployment as success. Warnings still exit 0, a fresh machine with no stacks is
unconfigured, not broken.
It is an MCP tool. clawops_doctor returns the same report as structured data, so an
agent that hits a failure can find out why. It reports only; it never runs openclaw doctor --fix. --json gives the CLI the same report.
clawops agents list stops inventing an empty listThe command ended in || echo '[]', so a stopped container, a gateway still starting, or a
Docker permission error all produced "No agents running.", a wrong answer rather than an
error. It now fails, and says which.
gateway restart, logs, monitor, backup, agents, config set and doctor's
container checks were all broken on AWS: clawops connects as ubuntu, but provisioning
only put clawops in the docker group, so every Docker command failed with permission denied. GCP and Azure connect as clawops, so only AWS was affected.
clawops agents restart and the clawops_agents_restart MCP tool. OpenClaw 2.0 has no
per-agent restart, only gateway restart and daemon restart, both of which interrupt
every agent on the host. Use clawops gateway restart, or stay on @clawops/cli@legacy.
clawops agents list and clawops agents logs are unaffected.
| Milestone | Status | What ships |
|---|---|---|
| M0: Scaffold | ✅ | Tooling, CI, stubs, generated types |
| M1: GCP MVP | ✅ | init / up / down / status / ssh / logs on GCP |
| M2: Remote Mgmt | ✅ | tunnel, config, agents, gateway; SSH connection pool |
| M3: AWS + Azure | ✅ | AWS EC2 + Azure VM adapters; stacks list |
| M4: Local VM | ✅ | Local adapter (SSH bootstrap, no Pulumi); doctor |
| M5: MCP Layer | ✅ | mcp serve (stdio), all CLI ops as MCP tools, progress tracking |
| M6: Plan/Apply | ✅ | plan + apply; deploy-plan schema; MCP HTTP transport; workflow_deploy_app |
| M7: v1.0 Polish | ✅ | Full doctor surface; destroy command; --dry-run across commands; CI guide |
See docs/roadmap.md for the public roadmap and upcoming work.
MPL-2.0, see LICENSE.