/ Docs
Self-hosted runtime

Operations and troubleshooting

Run a self-hosted Botyard runtime day to day: status, logs and audit lines, upgrades, restarts and storage, offline behaviour, and start-up refusals.

This page is for the platform team that runs the self-hosted runtime (botyardd) in its own Kubernetes cluster. It assumes the chart is installed as described in Install on Kubernetes. The self-hosted overview explains what runs where.

The commands below use <ns> for the namespace and <release> for the Helm release name. The pod is always <release>-botyard-runtime-selfhosted-0.

Check the runtime

kubectl -n <ns> exec <release>-botyard-runtime-selfhosted-0 -- botyardd status

botyardd status prints a report and exits 0 only when the runtime is enrolled, the daemon is running and it is reaching Botyard. The first line is the summary:

First lineMeaning
Connected to Botyard.Healthy.
Enrolled, but not currently reaching Botyard.Enrolled and running, but the connection is down. It retries on its own. Check egress and the proxy.
Not connected to Botyard — this machine is not enrolled.No enrollment on this volume. See Running, but the bot stays offline.
Not connected — the daemon started before this machine enrolled.Restart the pod so the daemon picks up the enrollment.

The report also shows the bot and device IDs, the daemon version and the state directory (/var/lib/botyard/state). If the device key line reads MISSING — re-enroll to mint a new one, the volume has lost the key. See Restarts and storage.

botyardd status works even when the proxy or CA settings are broken. When the pod is crashlooping, kubectl exec usually misses the container, so run the command in a one-off copy of the pod instead. The copy mounts the same volume and doesn't start the daemon:

kubectl -n <ns> debug <release>-botyard-runtime-selfhosted-0 --copy-to=botyard-diag \
  --same-node --container=botyardd -it -- botyardd status
kubectl -n <ns> delete pod botyard-diag

In the copy, the report says the daemon isn't running. That's expected, because the copy doesn't start it. Read the enrollment and device-key lines instead. botyardd config prints the effective configuration, and where each value came from, the same way.

Logs

kubectl -n <ns> logs <release>-botyard-runtime-selfhosted-0            # current container
kubectl -n <ns> logs <release>-botyard-runtime-selfhosted-0 --previous # the one that just crashed

The container writes two streams. Route them separately.

StreamWhat it isHow to recognise it
stderrThe daemon's own log: start-up, connection state, errors.One JSON object per line, with timestamp, level, fields.message and target. Lines from the entrypoint script start botyard-selfhosted: .
stdoutThe host-tool audit log: one line per host-tool call Botyard asked this runtime to make.Each line starts {"botyard_audit":"host_tool",.

A healthy start logs botyardd starting, then connected to the Botyard runtime gateway. Some lines look alarming but are expected:

  • WARN stronger key custody was unavailable. A container has no hardware key store, so the runtime keeps its device key on the volume and reports custody_tier=file.
  • The enrollment refusal on every restart. See Restarts and storage.
  • Unknown stream id 1, discarding message. kubectl prints this during kubectl exec … botyardd status. It is harmless.

Daemon log settings, set through the chart's extraEnv:

  • BOTYARD_LOG sets the filter. The default is info. Use debug while troubleshooting connectivity.
  • BOTYARD_LOG_FORMAT is json (the default) or text. Any other value stops the daemon with BOTYARD_LOG_FORMAT: expected one of json, text, got "<value>".

Audit lines

An audit line records what was asked and how it ended, never secrets or output. Fields, in order:

  • botyard_audit
  • ts_ms
  • bot_id
  • turn_id, when Botyard supplied one
  • source: control_plane or local_relay
  • tool
  • the target, as one of:
    • an exec's exe (basename only) and argc (argument count, never the arguments);
    • a file tool's path.
  • decision: allow or deny
  • reason, on a denial: local_policy, unknown_tool or not_on_this_seam
  • is_error
  • exit_code
  • duration_ms

Lines appear only for calls that reach the runtime. With the chart's default hostPolicy (nothing enabled), every command or file call that reaches it is refused, so any such line is a refusal (decision deny, reason local_policy). Whether you see any at all depends on which host tools the bot is assigned in Botyard. An audit line records a request; only decision allow means it ran.

Missing fields are left out, not written as null. Don't build alerting that assumes turn_id is always present.

If your log pipeline stalls, botyardd drops audit lines rather than slow a tool call down. The next line it writes is preceded by {"botyard_audit":"host_tool_dropped", … "dropped":N}, so the gap is visible. Audit lines are on by default. BOTYARD_HOST_TOOL_AUDIT=off turns them off, and any value other than on or off stops the daemon with BOTYARD_HOST_TOOL_AUDIT must be "on" or "off".

The Host policy and security page explains which tools can appear here.

Upgrades

You choose when to upgrade. The runtime has no self-update, and Botyard never changes the version you run. To upgrade, verify the new release as described in Verify what you pull, then:

helm upgrade <release> oci://ghcr.io/botyard-ai/charts/botyard-runtime-selfhosted \
  --version <X.Y.Z> -n <ns> --reuse-values \
  --set image.digest=sha256:<new-digest>

If you pinned image.digest, change it on every upgrade. A pinned digest wins over the chart's own image version, so bumping only --version (with --reuse-values) upgrades the chart and keeps the old image.

What happens during an upgrade:

  • The pod is replaced. The chart runs one replica. The old pod is stopped completely before the new one starts, so two runtimes never hold the device at once. Changing an inline hostPolicy value also replaces the pod, because the runtime reads its policy only at start-up.
  • Changes outside the chart's values don't restart anything. The runtime reads its settings once, at start-up. Editing your own hostPolicyConfigMap, the proxy Secret, or the CA bundle doesn't replace the pod, and the running daemon keeps its old settings. Restart it yourself: kubectl -n <ns> rollout restart statefulset/<release>-botyard-runtime-selfhosted.
  • The old daemon gets time to stop. It receives SIGTERM, logs botyardd shutting down, and has terminationGracePeriodSeconds (default 60) to stop cleanly.
  • A reply in progress is cut short. The turn is interrupted. When the new pod starts, the runtime reports it to Botyard as failed, so the conversation doesn't wait forever. The reply ends with: The bot's runtime restarted while this reply was in progress, so it was stopped. Any tools it had started may already have run; check before sending the message again. Tools the bot had started may have finished, so check what already happened before you send the message again.
  • Anything started with watch_process stops. Those processes run inside the container, so they stop with the pod. Botyard tells the bot the watched process ended without a recorded exit, or was lost to a runtime restart. The process may have done part of its work before it stopped, so check what it left behind before you start it again.
  • Messages sent during the restart get the offline answer. See While the runtime is offline.
  • Identity is kept. The new pod uses the same volume, so it doesn't re-enroll and doesn't need a new token.

Botyard doesn't currently enforce a minimum runtime version on self-hosted runtimes. Stay on a published release, and upgrade when Botyard asks you to.

Restarts and storage

The volume claim (state-<release>-botyard-runtime-selfhosted-0, or your persistence.existingClaim) holds the device key and the enrollment. The volume is the device.

  • Pod restarts, node moves and upgrades keep the device, as long as the same volume comes back. On every start after the first, the log shows the enrollment step refusing to run, then the daemon starting normally. This is expected and doesn't spend the token in the Secret:

    botyardd: this machine is already Botyard device <device-id> for bot <bot-id>.
    … refusing to enroll: this machine is already bound and --replace was not given
    botyard-selfhosted: state directory already enrolled; the enrollment token was not used

    The token stays in the pod spec, so a kubectl exec shell can read it from its own environment. It can't enroll anything once spent, but you can delete it from the Secret after the first successful start.

  • helm uninstall keeps the volume. Reinstalling with the same release name and namespace reuses it.

  • Losing the volume means enrolling again. Deleting the claim or the namespace, or losing the underlying disk, loses the device key. Botyard still lists the old device as the bot's active device. To recover:

    1. Revoke the old device on User menu › Settings › Bots › ‹your bot› › Device.
    2. Mint a new enrollment token.
    3. Update the Secret with the new token.
    4. Delete the claim (if anything is left of it) and the pod.

    Revoke before enrolling. While the old device is active, enrollment is refused with This bot already has an active device. Revoke the current device, generate a fresh enrollment token, and run the installer again….

  • Moving the runtime to a different bot is never done by a restart. Revoke the device, delete the claim, and enroll with the other bot's token. See the Enrollment page.

Back up the volume as you would any secret: whoever holds a copy of it holds the device identity. Revoke the device in Botyard if a copy may have leaked.

While the runtime is offline

A self-hosted bot only works while its runtime is connected. The runtime is offline while the pod is stopped, restarting, or can't reach Botyard.

In the app. User menu › Settings › Bots › ‹your bot› › Device shows Online while the device has been seen in the last 5 minutes, and Offline with Last seen … after that. The badge is a liveness hint and can lag a short outage. What decides whether a message is sent is the check below, made at send time.

Chat messages. Botyard waits up to 5 seconds for the runtime to reconnect. If it doesn't, the message isn't sent. A card under it reads The bot's machine is offline, followed by This message wasn't sent. Retry once the machine is back online. and Last seen …. Nothing ran, so choosing Retry once the runtime is back is safe.

Schedules and awakes. If the runtime is offline when a scheduled run or awake is due, Botyard keeps retrying for a reconnect window:

  • the window is the schedule's own wait, or 3 minutes if it sets none, with a maximum of 1 hour;
  • for a recurring schedule, the window also ends when the next run is due.

If the runtime reconnects within the window, the run happens late. If it doesn't, the run is recorded as missed, with the error the bot's machine did not reconnect within the wake's reconnect window, and isn't caught up later.

Watches. The runtime renews each watch_process watch about every 20 seconds. If it can't reach Botyard for longer than the lease (60 seconds) plus a grace period, Botyard ends the watch and tells the bot its watched work was lost to a runtime restart. A process that keeps running through a short network outage still reports its real exit when the runtime reconnects, if the watch hasn't been ended by then.

Reconnecting. The runtime reconnects by itself, with backoff. You don't need to restart the pod after a network outage. A device revoked in Botyard is different: the runtime logs Botyard refused this device; the daemon will not reconnect until it is enrolled again and stays disconnected until you enroll it again.

Troubleshooting

Find the symptom, then read the pod's logs (--previous for a crashloop). Every message quoted below is printed by the chart, the entrypoint or the runtime as shown.

Install or upgrade fails (helm install / helm upgrade)

The chart checks your values before anything is deployed. The error starts Error: execution error at (botyard-runtime-selfhosted/templates/statefulset.yaml…), followed by one of these messages:

MessageCauseFix
proxy.url "<url>": botyardd only accepts an http:// proxy URL (not https://, socks5://, ...)proxy.url isn't http://.Use your proxy's http:// address. The runtime tunnels TLS through it with CONNECT.
proxy.url "<url>": expected http://HOST[:PORT] with a real host (bracketed IPv6 allowed) and a port of 1-65535, or none for 80Empty host, bad port, or a path.Write it as http://proxy.example:3128.
proxy.url must not contain credentials (user:pass@). …Credentials in a plain value.Put the full URL in a Secret and set proxy.existingSecret.
hostPolicy.root "<path>" must not be, contain or sit inside the daemon state directory /var/lib/botyard/state: …The host-tool root overlaps the directory holding the device key.Use e.g. /var/lib/botyard/workspaces.
hostPolicy.root "<path>" is not a canonical path (repeated '/', '.' or '..' segments); write it as "<clean path>"The root contains //, . or ...Use the path the message suggests.
hostPolicy.root "<path>" must be an absolute pathRelative root.Start it with /.
hostPolicy.root "<path>" must be on the state volume (/var/lib/botyard/...) or /tmp: …The root filesystem is read-only, so the chart can't create the root anywhere else.Use a path under /var/lib/botyard/. To use your own volume, set hostPolicyConfigMap and create the root yourself.
extraEnv: <NAME> is managed by the chart (or must not be set on a self-hosted device); use the dedicated valueextraEnv sets a variable the chart owns, another proxy spelling, or a hosted-identity variable such as SPIFFE_ENDPOINT_SOCKET.Use the dedicated value (proxy, extraCABundle, hostPolicy, enrollment). Don't set the identity variables at all.
… set X OR Y, not bothTwo mutually exclusive values are set, for example proxy.url and proxy.existingSecret.Keep one.

The pod never starts (ContainerCreating)

If the CA ConfigMap or Secret named in extraCABundle, or the key in it, doesn't exist, the volume can't be mounted. The pod stays in ContainerCreating, botyardd never runs, and there are no container logs. kubectl -n <ns> describe pod <release>-botyard-runtime-selfhosted-0 shows a FailedMount event from Kubernetes. Create the ConfigMap or Secret, or set extraCABundle.key to the key that holds the PEM (default ca.pem).

The pod crashloops

Symptom in the logsCauseFix
cannot resolve the outbound network settings with HTTPS_PROXY is set but is not a usable proxy URL (expected e.g. http://proxy.example:3128)The proxy URL from proxy.existingSecret has no host, or a port that isn't a number from 1 to 65535. HTTP_PROXY and ALL_PROXY are checked the same way. The value itself is never logged, because it may contain a password.Fix the URL in the Secret, then delete the pod.
cannot resolve the outbound network settings with BOTYARD_EXTRA_CA_BUNDLE=<path>: cannot read the CA bundle: …The runtime can't read the bundle file. The chart mounts it at /etc/botyard/ca/<extraCABundle.key> with Kubernetes' default mode 0644, which uid 10001 can read. With the chart, this means something changed the file's permissions, such as an admission policy. Without the chart, BOTYARD_EXTRA_CA_BUNDLE points at a missing or unreadable path.Without the chart, fix the path in BOTYARD_EXTRA_CA_BUNDLE. With the chart, make the mounted file readable by uid 10001. Then delete the pod.
cannot resolve the outbound network settings with BOTYARD_EXTRA_CA_BUNDLE=<path>: no certificate in the bundle, not a valid PEM certificate: … or not usable as a trust anchor: …The bundle is empty, or isn't PEM certificates.Store PEM-encoded CA certificates (-----BEGIN CERTIFICATE-----).
botyard-selfhosted: enrollment failed (exit 1); mint a fresh token in Botyard and update the Secret, after botyardd: This enrollment token is no longer valid. It may have already been used, been replaced by a newer token, or expired. …The token was already spent, replaced by a newer token, or is older than 7 days. Botyard answers 403 (or 404 for a token it never issued) and records the attempt in its audit log.Mint a new token, put it in the Secret, delete the pod.
The same, but you never started the pod with this token beforeGenerate install command on User menu › Settings › Bots › ‹your bot› › Device gives a curl … | sh -s -- --token <token> command. Running that command anywhere (for example, to try it on a workstation) installs the desktop runtime there and spends the token.Don't run the install command. Mint a new token and copy only the token value into the Secret.
botyardd: unexpected response from Botyard: This bot already has an active device. …The bot still has an active device, for example after the volume was lost or replaced.Revoke the old device on User menu › Settings › Bots › ‹your bot› › Device, mint a new token, update the Secret, delete the pod.
botyardd: cannot reach Botyard: error sending request for url (https://api.botyard.io/v1/public/device-enrollment/redeem)First enrollment can't reach Botyard: egress blocked, DNS, proxy or TLS inspection.See Egress is blocked.
cannot load host policy with invalid BOTYARD_HOST_POLICY JSON: …Malformed JSON in your hostPolicyConfigMap.Fix the JSON. The chart only checks hostPolicy, not a ConfigMap you provide.
cannot initialize host tools with host file operation failed: No such file or directory (os error 2)The host-policy root doesn't exist. The message doesn't name the path. The chart creates the root for hostPolicy, but not for hostPolicyConfigMap.Create the root directory named in your policy, on the volume and owned by uid 10001, then delete the pod.
cannot bind the control socket with cannot restrict the Botyard control socket at <dir> to this userThe state directory isn't owned by the runtime's user (10001). This happens when the image runs without the chart and BOTYARD_HOME points at the root of a volume, which usually arrives owned by root.Point BOTYARD_HOME and HOME at a child of the mount, as the chart does (/var/lib/botyard/state). The runtime creates it with the right owner and mode.

Running, but the bot stays offline

SymptomCauseFix
this machine is not enrolled; run `botyardd enroll --token ...` A new volume, and no token reached the pod.Set enrollment.existingSecret to a Secret holding a fresh token, then delete the pod.
pod identity source not ready; not connecting to Botyard, about 10 seconds after startThe volume isn't enrolled, no token reached the pod, and SPIFFE_ENDPOINT_SOCKET is in the pod (typically injected by a service mesh or SPIRE webhook). An unenrolled runtime takes that variable as Botyard's hosted identity and doesn't connect.Provide a token (see the row above) and exclude the namespace or pod from mesh identity injection, then delete the pod. Once enrolled, the runtime ignores the variable and logs this daemon is enrolled, so it runs as a self-hosted device; pod identity variables in its environment are ignored.
Botyard refused this device; the daemon will not reconnect until it is enrolled againThe device was revoked.Enroll again: delete the claim, mint a token, update the Secret, delete the pod.
No errors, botyardd status says Enrolled, but not currently reaching Botyard.The connection to Botyard is failing.See Egress is blocked.

The bot is online, but replies fail

SymptomCauseFix
The reply is empty, with No credential is configured for provider scope 'llm'. Add one under Settings -> Credentials in Botyard. This will not resolve on its own.The runtime is connected and took the turn, but no model (LLM) credential is bound to this bot. The app may still show the bot as ready.Add a model credential under User menu › Settings › Credentials › LLM Providers if the organization has none, bind it to this bot for the llm scope with a default model, then send the message again.
The bot says a host tool such as process_start or file_read "isn't available"The tool isn't assigned to the bot in Botyard. Assignment decides what the bot is offered; your local policy doesn't.Assign the tool to the bot in Botyard. Also allow the operation in hostPolicy.enabled, or the call is refused (next row).
The bot calls a host tool and it's refusedThe operation isn't in hostPolicy.enabled. The chart's default hostPolicy enables nothing. The audit log has a line for the call with decision deny and reason local_policy.Add the operation to hostPolicy.enabled and set hostPolicy.root to an absolute directory on the volume, then run helm upgrade. The runtime needs root whenever anything is enabled, including only exec or process_start. Without it, the runtime stops at start-up with cannot initialize host tools. See the Host policy and security page.

Egress is blocked

For its own control-plane traffic, the runtime needs DNS and outbound HTTPS (TCP 443), directly or through your proxy, and Botyard never connects in to it. (Commands it starts through exec and services it supervises are ordinary processes in the pod and can listen or use other ports; the chart's NetworkPolicy is off by default.) It connects to api.botyard.io, and to the runtime gateway that Botyard names at connect time (today wss://runtime.botyard.io). Moving files between the bot and Botyard can also reach Botyard's object storage, which today is hel1.your-objectstorage.com (or a bucket host under it), not a botyard.io host. If you restrict egress by name, allow those hosts, then check the debug log for anything still blocked.

Today, a running runtime that can't connect retries quietly. At the default log level it writes no error for this, and botyardd status is the only sign. To see why it can't connect, add BOTYARD_LOG=debug to extraEnv and look for cannot open a Botyard session; will retry and cannot reach the Botyard runtime gateway. Remove it afterwards.

Common causes:

  • networkPolicy.enabled with a proxy. The policy allows TCP 443 only. Add your proxy's port to networkPolicy.extraEgressPorts (e.g. [{port: 3128, protocol: TCP}]), and its address to networkPolicy.egressCIDRs if you restrict destinations.
  • A proxy URL that isn't http://. A credentialed proxy set through proxy.existingSecret isn't checked by Helm. The runtime currently starts with an https:// or socks5:// proxy, then fails every connection to Botyard with proxy scheme "https" is not supported for WebSocket dials; use an http:// proxy URL, visible only at debug. Use the proxy's http:// address.
  • NO_PROXY covers Botyard. Hosts that match proxy.noProxy are dialled directly. Make sure botyard.io isn't in it if direct egress is blocked.
  • TLS inspection. If your proxy re-signs TLS, add its CA with extraCABundle. Without it, connections fail certificate checks.

Image pulls

SymptomCauseFix
ImagePullBackOff from a private mirror such as ECRThe mirror needs credentials or the repository path is wrong. The host-tool-root init container uses the same image.Set image.repository to the mirror, add imagePullSecrets (or node IAM for ECR), and keep image.digest. cosign copy keeps the digest and signatures.
403 when pulling or verifying the public image or chart, from your workstationStale ghcr.io credentials in ~/.docker/config.json, or in the Helm or cosign registry config. They are sent even for public packages and are rejected.Run docker logout ghcr.io and helm registry logout ghcr.io, or use a clean DOCKER_CONFIG. No credentials are needed.

Contacting Botyard support

Send:

  • the chart version and image digest (helm -n <ns> list, and the image of the pod);
  • the output of botyardd status;
  • the stderr log around the problem, with --previous for a crashloop;
  • what you changed last: values, proxy, network policy, volume.

Never send an enrollment token, a proxy URL that contains a password, the contents of the state volume, or any other secret. Redact them from logs and values before you send anything. If a token was exposed, mint a new one, which replaces the old token. If the device key may have been exposed, revoke the device.

On this page