docs/book/src/setup/freebsd.md
ZeroClaw runs natively on FreeBSD (tested on FreeBSD 15.0-RELEASE, amd64). Two things differ from the Linux/macOS/Windows paths:
install.sh support. FreeBSD is not a target of the bootstrap installer, so you build from source with the system Rust toolchain.zeroclaw service backend. The zeroclaw service install command knows systemd, OpenRC, launchd, and Windows Task Scheduler, not FreeBSD rc.d. You install a small rc.d script yourself. This page gives you a complete, tested one.Everything else, config, providers, channels, the daemon, the gateway, is identical to any other platform.
When to use FreeBSD. FreeBSD deployments are common in network-appliance, embedded, and jail-based hosting where operators want the base system’s stability, ZFS + jail primitives, or need FreeBSD for policy/licensing reasons. Because there is no prebuilt binary and the
rc.dsetup is manual, this path suits operators comfortable with FreeBSD conventions. If you are only evaluating platforms with no specific FreeBSD requirement, Linux (systemd) or macOS (launchd) onboard faster viainstall.shandzeroclaw service install.Grab the files instead of copy-pasting. Every shell script and sample config shown below ships in
dist/freebsd/: copy them to your host directly. The walkthrough explains what each piece does and why.
Install the toolchain and runtime from pkg:
doas pkg install -y rust git
| Package | Why |
|---|---|
rust | Provides cargo and rustc to build the binary. ZeroClaw's workspace MSRV is Rust 1.87; the FreeBSD rust port tracks a newer stable, so pkg install rust satisfies it. |
git | Cloning the repo, and required at runtime if you use any git-backed tools. |
doas, notsudo. FreeBSD shipsdoasas the base privilege-escalation tool;sudois an optional port. The examples here usedoas. A minimal/usr/local/etc/doas.confgranting thewheelgroup passwordless escalation is:permit nopass keepenv :wheel
git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
cargo build --release
The release binary lands at target/release/zeroclaw. A clean build of the default feature set takes a while on modest hardware, this is expected; ZeroClaw is a large Rust workspace.
To trim the build, disable features you don't need (see ./install.sh --list-features on a Linux box, or Cargo.toml):
cargo build --release --no-default-features --features agent-runtime
Put it somewhere on PATH. /usr/local/bin is the conventional location for ports-installed binaries on FreeBSD:
doas install -m 755 target/release/zeroclaw /usr/local/bin/zeroclaw
zeroclaw --version
(~/.cargo/bin/zeroclaw works just as well if you'd rather keep it per-user.)
zeroclaw quickstart
This creates ~/.zeroclaw/ with a starter config and walks you through provider setup. Config layout and precedence are identical to every other platform: see Reference → Config.
Provider auth is not FreeBSD-specific. API-key providers just need the key set through the gateway, zerocode, zeroclaw config set, or the environment. OAuth and subscription providers (e.g. an OpenAI/Codex ChatGPT subscription, Anthropic Claude Pro/Team) get their token from the vendor's own dashboard or login flow, which you then configure the same way you would an API key.
For the full credential model (API keys, OAuth/subscription tokens, env overrides, and the secrets store), see Provider Configuration → Credentials and OAuth and subscription auth. That page is the source of truth for every platform.
rc.d)Because zeroclaw service install has no FreeBSD backend, supervise the daemon with FreeBSD's native daemon(8) under an rc.d script. This gives you service zeroclaw start|stop|restart|status, restart-on-crash, a pidfile, and boot-time startup.
Ready-to-install copies of every script below live in
dist/freebsd/(zeroclaw-run.sh, the basiczeroclaw.rc, and the hardenedzeroclaw-hardened.rc). The tworc.dscripts carry a@@ZEROCLAW_USER@@placeholder yousedin on install, so you can grab the files instead of copy-pasting: seedist/freebsd/README.md. The walkthrough below explains what each piece does.
daemon(8) starts the child with a minimal environment, so export a full PATH (FreeBSD puts git, python3, etc. under /usr/local/bin, which is not on the default service PATH). The rc.d script runs this through daemon -u <user>, which per daemon(8) sets HOME, USER, and SHELL from that account's passwd entry before exec, so ${HOME} is already the service account's home (accounts whose home is elsewhere, and rc.conf run-as overrides, just work). Save as /usr/local/libexec/zeroclaw-run.sh:
#!/bin/sh
# daemon -u <user> has already set HOME from the account's passwd entry.
export PATH="/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:${HOME}/bin"
exec /usr/local/bin/zeroclaw daemon --config-dir "${HOME}/.zeroclaw"
doas install -m 755 zeroclaw-run.sh /usr/local/libexec/zeroclaw-run.sh
rc.d scriptSave as /usr/local/etc/rc.d/zeroclaw:
#!/bin/sh
#
# PROVIDE: zeroclaw
# REQUIRE: NETWORKING DAEMON
# KEYWORD: shutdown
. /etc/rc.subr
name="zeroclaw"
rcvar="zeroclaw_enable"
load_rc_config $name
: ${zeroclaw_enable:="NO"}
# Do NOT name this ${name}_user — rc.subr would then run its own su user-switch
# and collide with daemon -u ("failed to set user environment").
: ${zeroclaw_runas:="youruser"}
rundir="/var/run/zeroclaw"
pidfile="${rundir}/zeroclaw.pid"
logfile="/var/log/${name}.log"
launcher="/usr/local/libexec/zeroclaw-run.sh"
command="/usr/sbin/daemon"
command_args="-r -P ${pidfile} -o ${logfile} -u ${zeroclaw_runas} ${launcher}"
start_precmd="zeroclaw_precmd"
zeroclaw_precmd()
{
# rundir + logfile stay root-owned: rc.d (root) writes the daemon -P pidfile
# here and trusts it later, so the unprivileged service user must not be able
# to forge it. daemon -o opens the logfile before dropping to ${zeroclaw_runas}.
install -d -o root -g wheel -m 755 "${rundir}"
install -o root -g wheel -m 640 /dev/null "${logfile}"
}
run_rc_command "$1"
doas install -m 755 zeroclaw /usr/local/etc/rc.d/zeroclaw
What the flags do:
-r: supervise and restart the child if it exits (crash recovery).-P ${pidfile}: write the supervisor's pid so service zeroclaw stop can signal it.-o ${logfile}: redirect the child's stdout/stderr to a logfile.-u ${zeroclaw_runas}: run zeroclaw as an unprivileged user, not root.Why
daemon -uand notsu -m. A common pattern isdaemon ... su -m user -c launcher. Avoid it:su(1)does not forwardSIGTERMto its child, soservice zeroclaw stopkills thedaemonsupervisor but leaves an orphanedzeroclawprocess behind, and the nextstartstacks a second copy.daemon -u usermakesdaemon(8)the direct parent ofzeroclaw, so it forwards the stop signal and shuts down cleanly. (If you're stuck with asu-based script for other reasons, add apkill -f "zeroclaw daemon"sweep to its stop path.)
doas sysrc zeroclaw_enable=YES
doas sysrc zeroclaw_runas=youruser # the account that owns ~/.zeroclaw
doas service zeroclaw start
doas service zeroclaw status
service zeroclaw stop / restart work as expected. Because zeroclaw_enable=YES is in /etc/rc.conf (written by sysrc), the daemon also starts on boot.
The script above is correct for an interactive, single-instance install. Three daemon(8) behaviours will surprise you the moment you drive the service remotely (over ssh) or run more than one copy. All three bit a production deployment; the fixes are small. A complete script folding in every fix below ships as dist/freebsd/zeroclaw-hardened.rc: install it in place of the basic zeroclaw script.
Remote service ... start hangs. daemon -r inherits and holds open whatever stdin/stdout/stderr it was launched with. Run ssh host 'service zeroclaw start' and the supervisor keeps your ssh session's stdout fd open forever, so ssh never sees EOF and the command hangs even though the daemon started fine. Detach the supervisor's own descriptors: -o ${logfile} already routes the child's output, so nothing is lost:
command_args="-r -P ${pidfile} -o ${logfile} -u ${zeroclaw_runas} ${launcher}"
# ...invoke daemon with its own std{in,out,err} sent to /dev/null:
/usr/sbin/daemon ${command_args} </dev/null >/dev/null 2>&1
If you use the stock command/command_args form, wrap the start in a custom start_cmd so you control the redirection. This one change is what makes service zeroclaw start safe to call from ssh, CI, or a config-management push.
Repeated start stacks orphan supervisors. A plain start does not check whether a supervisor is already running, so a second start (or a start after a crash that left a stale pidfile) launches another daemon that fights the first over the gateway port. Make start idempotent by refusing when a live supervisor already exists. Match the supervisor by the launcher path, not the pidfile alone (the pidfile can be stale). Two FreeBSD-specific traps when you do this:
daemon(8) retitles its supervisor to daemon: /usr/local/libexec/zeroclaw-run.sh[<childpid>] (daemon). So pgrep -f zeroclaw-run.sh matches the supervisor, but a pgrep -f for the binary name does not. Bind on the literal daemon: prefix: that matches the supervisor and never the child, a hand-run of the launcher, or the rc shell itself. Bind the trailing [ that opens daemon's [<childpid>] too, so a sibling launcher whose name merely starts with zeroclaw-run.sh can't match (this matters once you run a pool, see Running a pool of instances below).pgrep -f does not honour a leading ^ anchor against that retitle string: pgrep -f '^daemon: ...' matches nothing. Drop the ^; rely on the daemon: prefix for specificity and escape the dot in .sh as [.] (and the bracket as [[]) so they are literal.launcher_pat="daemon: /usr/local/libexec/zeroclaw-run[.]sh[[]"
zeroclaw_running()
{
pgrep -f "${launcher_pat}" >/dev/null 2>&1
}
read from a daemon -P pidfile reports a false negative. daemon -P writes the pid with no trailing newline, so IFS= read -r pid < "${pidfile}" returns a non-zero status (EOF before newline) even though it set pid correctly. If you guard it as read -r pid < "$pf" || return 1, every running instance looks stopped and your idempotent start happily launches a duplicate. Don't key the success path on read's exit status: validate the value instead:
pid=""
IFS= read -r pid < "${pidfile}" # do NOT `|| return 1` here
case "${pid}" in
''|*[!0-9]*) return 1 ;; # empty or non-numeric → treat as not running
esac
Running a pool of instances. To run N daemons (e.g. a worker pool), give each its own pidfile and logfile (worker.$i.pid, worker.$i.log) and loop the start/stop over $i. Because the supervisor retitle is identical for every instance and does not include per-instance arguments, the pidfile is the only per-instance handle: drive stop/status from the pidfile, and on a full stop sweep any leftover supervisor that no live pidfile points at (started by hand, or whose pidfile went stale).
Jails give ZeroClaw an isolated root with its own packages, service user, and optionally its own IP, useful if the host runs other services or you want to constrain the agent. The service setup is identical to the host case; you just run it inside the jail. This walks through a classic thick jail with base-system tooling (no jail manager required).
One-step option.
dist/freebsd/zeroclaw-jail-setup.shautomates steps 1–3 below: it creates the jail, extracts a matching base, adds the/etc/jail.confentry, starts the jail, and installs the launcher + hardenedrc.dscript inside it (doas sh zeroclaw-jail-setup.sh, withJAIL_NAME/JAIL_PATH/ZPOOL/ZEROCLAW_USERoverridable via env). The manual walkthrough below explains what it does.
# ZFS dataset for the jail (use a plain directory if you're on UFS).
doas zfs create -o mountpoint=/jails/zeroclaw zroot/jails/zeroclaw # adjust pool
# Extract a base matching the HOST's release into it.
doas fetch -o /tmp/base.txz \
"https://download.freebsd.org/releases/$(uname -m)/$(freebsd-version -u)/base.txz"
doas tar -xpf /tmp/base.txz -C /jails/zeroclaw
doas cp /etc/resolv.conf /jails/zeroclaw/etc/
Add a jail entry to /etc/jail.conf (host side). This example shares the host network; set ip4.addr instead if you give the jail a dedicated address.
zeroclaw {
host.hostname = "zeroclaw";
path = "/jails/zeroclaw";
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
exec.clean;
mount.devfs;
persist;
}
doas sysrc jail_enable=YES
doas sysrc jail_list+=" zeroclaw"
doas service jail start zeroclaw
Everything from the sections above runs inside the jail: prefix commands with doas jexec zeroclaw …, or open a shell with doas jexec zeroclaw /bin/sh:
doas jexec zeroclaw pkg install -y rust git # or copy a binary built on the host
# build + install zeroclaw to /usr/local/bin/zeroclaw exactly as above, then:
doas jexec zeroclaw pw useradd zeroclaw -m -s /usr/sbin/nologin
Install the launcher and rc.d script into the jail's filesystem (from the host, the jail root is prefixed: /jails/zeroclaw/usr/local/libexec/… and /jails/zeroclaw/usr/local/etc/rc.d/…). Then enable and start the service inside the jail:
doas jexec zeroclaw sysrc zeroclaw_enable=YES
doas jexec zeroclaw service zeroclaw start
doas jexec zeroclaw service zeroclaw status
tee, not cp /dev/stdin. Pipe through … | doas tee /jails/zeroclaw/usr/local/etc/rc.d/zeroclaw >/dev/null; doas cp /dev/stdin … can fail mid-copy with cp: /dev/stdin: File changed.--host 0.0.0.0 (edit zeroclaw-run.sh) and give the jail a reachable address, or proxy from the host.rc.d script in a jail. You'll typically drive service non-interactively via jexec/ssh, which is exactly where the basic script's start hang and orphan-stacking bite: see Hardening. It also keeps /var/run/zeroclaw root-owned inside the jail so the unprivileged service user can't forge the supervisor pidfile.pgrep bound to the launcher retitle, since the jail shares one process table.The native build above is the right path for ZeroClaw itself. But some Python-backed
tools and skills depend on manylinux-only wheels: polars, pyarrow, and
oracledb, for example, publish no FreeBSD wheels, so a tool that imports them can't
run under the native FreeBSD python3. FreeBSD's Linuxulator
(Linux binary-compatibility layer) plus Podman lets you run the official Linux
container image on a FreeBSD host, giving those tools the Linux ABI they expect.
This complements the native rc.d daemon: you can run either, or both side by side.
Enable the Linux ABI and confirm it reports a Linux release:
<div class="os-tabs-src">doas sysrc linux_enable="YES"
doas service linux start # loads the modules and mounts /compat/linux
sysctl compat.linux.osrelease # e.g. compat.linux.osrelease: 5.15.0
linux_enable="YES" in /etc/rc.conf also loads the ABI on boot. Then install Podman:
doas pkg install -y podman
FreeBSD Podman defaults to os=freebsd when resolving a manifest list. ZeroClaw's
images are published only for linux/amd64 and linux/arm64, so a plain podman pull
fails with no image found in manifest list for architecture ..., OS freebsd. Force
the Linux platform explicitly:
doas podman pull --os linux --arch amd64 ghcr.io/zeroclaw-labs/zeroclaw:debian
Use the
debiantag rather thanlatest: the distrolesslatestimage has no shell, which makes it awkward to debug under emulation. See Docker & Containers for the full image list.
The Linux image behaves exactly as documented in Docker & Containers,
it expects persistent state at /zeroclaw-data and bootstraps a config on first run:
doas podman run -d --name zeroclaw --restart=always \
--os linux --arch amd64 \
-p 42617:42617 \
-v /var/db/zeroclaw:/zeroclaw-data \
ghcr.io/zeroclaw-labs/zeroclaw:debian
doas podman exec -it zeroclaw zeroclaw quickstart
Keep the --os linux --arch amd64 flags on every run (not just pull) so Podman
doesn't re-resolve to the FreeBSD default.
--restart=always only restarts the container
within a running Podman; it won't survive a host reboot on its own. Supervise the
podman start from an rc.d script (same pattern as the native service)
or a @reboot cron entry so the container comes back after the host restarts.-p 42617:42617 publishes the gateway through Podman's bridge. If
the bridge/CNI setup isn't configured on your host, --network host is the simplest
alternative: the container then shares the host's network stack directly.dmesg for linux: warnings before assuming a ZeroClaw bug.tail -f /var/log/zeroclaw.log
Set the log level via the standard config / env knobs: see Operations → Logs & observability.
zeroclaw --version
service zeroclaw status
# if the daemon exposes the local gateway (default 127.0.0.1:42617):
fetch -qo - http://127.0.0.1:42617/health
A "status":"ok" health payload means the gateway is up; the response's runtime field carries the per-component health (channels, providers, and so on).
doas service zeroclaw stop
doas sysrc -x zeroclaw_enable
doas rm /usr/local/etc/rc.d/zeroclaw /usr/local/libexec/zeroclaw-run.sh
doas rm /usr/local/bin/zeroclaw
rm -rf ~/.zeroclaw # optional — deletes config + history