---
title: OpenClaw
description: 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.

```ts
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.

```ts
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:

```ts
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

```ts
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:

```ini
[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.target` holds the start until they are back.
- **`User=agent` still sees the values.** The files are root-owned and mode 0600, but systemd
  reads `EnvironmentFile=` before it drops privileges. A process opening the file itself as
  `agent` will 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:

```ts
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

```ts
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

```ts
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
  with `network_mode: host`; a `ports:` mapping yields a connection that times out.
- **Revoking a secret cannot recall a copy.** `machine.secrets.delete` removes 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.
