OpenClaw
Running a persistent agent daemon on a machine, with credentials that survive a restart and never enter a snapshot.
OpenClaw is a long-lived assistant process: it holds channel connections, serves its own web UI, and keeps state on disk. That is a different shape from the other integrations in this section. The adapters hand a machine to an agent running somewhere else; here the agent runs on the machine, and what you need from the product is a computer that stays put.
Nothing in this page is OpenClaw-specific. It is the pattern for any supervised process that has to come back after a stop.
you -> sdk -> machine -> systemd -> openclaw -> port preview -> the internet
What a machine gives it
- A disk that persists. Installed packages, its config directory and its own state survive stop, start and snapshot, because the disk is what a capture holds.
- A browser it can drive. Chromium and Firefox are in the image, with a real X display behind them.
- One public hostname per port, with WebSocket upgrades carried through, which is what its live UI needs.
- Credentials that never enter a capture. Secrets are delivered to tmpfs, so a snapshot, a fork and a template made from this machine carry none of them.
Provision
Use standard or larger. small is 2 vCPU and 4 GB, which OpenClaw plus a Chromium it drives
will not be comfortable in.
import { Client, type Machine } from "@raster/sdk";
// reads RASTER_BASE_URL and RASTER_API_KEY when you pass nothing.
const client = new Client();
const machine = await client.machines.create({
name: "openclaw-prod",
size: "standard",
metadata: { role: "openclaw" },
idempotencyKey: "openclaw-prod-v1",
});
await machine.waitForDesktop({ timeoutMs: 300_000 });
The idempotencyKey is doing real work. Without it, re-running your provisioning script mints a
second machine and bills for it; with it, the call converges on the one you already made.
Credentials
Names must be uppercase environment variable names, and stored values are never readable back: there is no read endpoint and the response shape has no field a value could travel in.
await Promise.all([
machine.secrets.set("ANTHROPIC_API_KEY", process.env.ANTHROPIC_API_KEY!),
machine.secrets.set("TELEGRAM_BOT_TOKEN", process.env.TELEGRAM_BOT_TOKEN!),
machine.secrets.set("OPENCLAW_ADMIN_TOKEN", crypto.randomUUID()),
]);
Delivery is asynchronous. An install step that reads a secret can outrun it, so wait for the machine to have taken delivery before you depend on one:
async function awaitDelivery(machine: Machine, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const pending: string[] = [];
for await (const secret of machine.secrets.list()) {
if (secret.delivered_at === null) pending.push(secret.name);
}
if (pending.length === 0) return;
if (Date.now() > deadline) throw new Error(`undelivered: ${pending.join(", ")}`);
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}
await awaitDelivery(machine);
delivered_at is null while a secret is stored but not yet in the machine, which is the normal
state for one set on a machine that is not running. It lands on the next boot.
Install
const install = await machine.terminal.execShell(
`set -euo pipefail
install -d -o agent -g agent /opt/openclaw
su agent -c 'git clone --depth 1 <openclaw-repo> /opt/openclaw'
cd /opt/openclaw && su agent -c 'pnpm install --frozen-lockfile && pnpm build'`,
{ user: "root", timeoutMs: 900_000 },
);
if (install.exit_code !== 0) throw new Error(install.stderr);
A non-zero exit is a result, not a thrown error, so check exit_code yourself. execShell
asks for a shell by name rather than tokenizing a string, because guessing where a command ends is
where injection lives.
The daemon, and the one thing to get right
Secrets are delivered to /run/computer/secrets.env on tmpfs and exported by
/etc/profile.d/10-computer-secrets.sh, which runs for login shells. A systemd unit does not
source profile.d, and pointing EnvironmentFile= at that file is worse than nothing: it is
shell-quoted, systemd’s quoting rules are close to a shell’s but not the same, and a value
containing a quote or a newline is silently corrupted, which is the worst way for a credential to
fail.
Daemons get their own rendering. Every delivery also writes /run/computer/secrets.systemd.env
in systemd’s own env-file format, which is lossless for any value, and raises
computer-secrets.target once it has landed. Both arrive on machines that already exist; no image
rebuild is involved. Order against the target and read that file:
[Unit]
Description=OpenClaw
Wants=computer-secrets.target
After=computer-secrets.target network-online.target
[Service]
Type=simple
User=agent
WorkingDirectory=/opt/openclaw
EnvironmentFile=/run/computer/secrets.systemd.env
ExecStart=/usr/bin/node dist/main.js --port 8080
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Three details carry the weight:
- The ordering handles the tmpfs. Both secrets files are gone after a stop and re-delivered on
the next boot;
After=computer-secrets.targetholds the start until they are back. User=agentstill sees the values. The files are root-owned and mode 0600, but systemd readsEnvironmentFile=before it drops privileges. A process opening the file itself asagentwill be refused.- A missing delivery fails loudly. The target is gated on delivery with a 300 second bound. If
it times out the target fails, the unit starts anyway, and the hard
EnvironmentFile=(no-prefix) fails the start where you can see it. Keep the-off: a daemon that cannot get its credentials should refuse to run, not run without them.
Install the unit and enable the service:
await machine.terminal.execShell(
`set -euo pipefail
cat > /etc/systemd/system/openclaw.service <<'UNIT'
${service}
UNIT
systemctl daemon-reload
systemctl enable --now openclaw.service`,
{ user: "root" },
);
Unit files go in through a root shell rather than through machine.files.write. The file API
runs as root and can write there, but it hands every path it creates to the machine’s user, and a
unit file owned by the account the unit runs as is not what you want.
The public endpoint
const ui = await machine.ports.expose(8080, { name: "openclaw-ui", access: "private" });
console.log(ui.access_url);
private is the default and mints a credentialed access link. The URL and its secret are in that
response and no other, because a value the product can hand back on request is a value that lives
in every log the response passes through.
Use access: "public" only for an endpoint that genuinely cannot send an Authorization header,
such as a channel webhook receiver. The proxy carries WebSocket upgrades, so a live UI works
through it unchanged.
Make it reproducible
const template = await machine.saveAsTemplate({
name: "openclaw-base",
description: "OpenClaw and its units, no credentials",
wait: true,
});
const staging = await client.machines.create({
name: "openclaw-staging",
size: "standard",
template: template.id,
});
This is the payoff, and it is why there is no dashboard page for any of this. The template holds the install and the unit files and holds no credentials, because secrets live on tmpfs and a capture cannot reach them. So one template serves every environment, and each machine made from it brings its own secrets.
Worth knowing
- A stop is a cold boot, not a resume. There are no memory snapshots. OpenClaw restarts from its unit and reads its state off the disk; anything it was holding only in RAM is gone.
- Container port publishing does not remap. The guest kernel has no
CONFIG_NF_TABLES, so every container runs in the host network namespace. If OpenClaw’s stack uses Compose, write it withnetwork_mode: host; aports:mapping yields a connection that times out. - Revoking a secret cannot recall a copy.
machine.secrets.deleteremoves it from storage and from the running machine. A process that already read the value and wrote it elsewhere still has it. Revoking at the source is this product’s half; rotating at the issuer is yours. - Close what you open. A provisioning script that took an input lease should
await machine.close().
The install commands above name a repository and a build that this page does not pin. Take the shape from it, and the version from OpenClaw’s own documentation.