Hubzoid
Deploy and operate

Single server

Run one or more hubs on a Linux server with systemd, put a reverse proxy in front for TLS, and set the values a public deployment needs.

hubzoid run <hub> is the production entry point for a hub. On a server you wrap it in systemd, keep every Hubzoid port on loopback, and let a reverse proxy you already trust terminate TLS on port 443. The walkthrough below uses Ubuntu 24.04. The same steps work on Debian and RHEL-family systems with the package manager swapped.

When to use this

Use this setup for one to a few hubs on a single Linux machine, each with its own chat app and URL. It is the simplest and cheapest production shape. If several teams should share one sign-in and one Console, run a multi-hub gateway instead. If pip install is impractical on the host, use Docker.

As a starting point, plan for 2 GB of memory for one hub and 4 GB for two or three hubs on the same machine.

What runs

hubzoid run starts and supervises these processes. When the bridge exits, the command stops the others.

ProcessBindStarted by
Edge--host (default 127.0.0.1, or HUBZOID_HOST) on PORT (default 3080)Always
Chat app (Open WebUI)127.0.0.1 on PORT + 40000, or HUBZOID_OWUI_PORTAlways, unless --no-ui
Bridge127.0.0.1 on BRIDGE_PORT (default 8000)Always
Slack adapterOutbound Socket Mode connection, no port--slack
Inbound server127.0.0.1 on HUBZOID_INBOUND_PORT (default 8100)--whatsapp, --telegram or --webhook

The edge is the only process your proxy talks to. It serves these public paths:

PathGoes to
/ and everything not listed belowThe chat app, including its websockets
/portal/The Console, served by the bridge
/artifacts/...Signed download links for files the agent made
/mcpThe hosted MCP server, when the hub sets MCP_SERVER=true
/webhooks/<hub>/<surface>The inbound server, when an inbound surface is running

The bridge's /v1, /uploads and /healthz routes are never forwarded. The edge also refuses . and .. path segments and drops client-sent X-Hubzoid-* and X-OpenWebUI-* headers.

Deploy a hub

Prepare the server

Install Python 3.12 and the build dependencies, then create a service user:

sudo apt update && sudo apt install -y \
  python3.12 python3.12-venv pkg-config ffmpeg build-essential git curl
sudo useradd -r -m -d /opt/hubzoid -s /bin/bash hubzoid

pkg-config and ffmpeg are the PyAV dependencies that most often fail on a fresh machine.

Open only the ports you need

In the firewall or security group, allow inbound traffic on:

  • 22/tcp from your administration address, for SSH
  • 80/tcp from anywhere, for certificate issuance and the redirect to HTTPS
  • 443/tcp from anywhere

Block everything else. The bridge binds to 127.0.0.1, so it is unreachable from outside the machine even with a permissive firewall.

Install Hubzoid and your hubs

Keep your hubs in a git repository with one folder per hub and a requirements.txt that pins the release, for example hubzoid==1.0.1 (or hubzoid[postgres]==1.0.1 for PostgreSQL).

sudo -iu hubzoid
git clone git@github.com:your-org/your-hub-agents.git agents
cd agents
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Configure each hub's .env

A hub's .env holds its secrets, so it is listed in the hub's .gitignore and never arrives with git clone. Create it on the server and make it readable by the service user only:

cd /opt/hubzoid/agents/devops-agent
touch .env && chmod 600 .env

A public deployment needs at least these values:

devops-agent/.env
MODEL=anthropic/claude-haiku-4-5
ANTHROPIC_API_KEY=<your key>
BRIDGE_API_KEYS=<openssl rand -hex 32>

WEBUI_AUTH=true
WEBUI_SECRET_KEY=<openssl rand -hex 32>
WEBUI_URL=https://devops.agents.example.com

PORT=3080
BRIDGE_PORT=8000
SettingWhy it matters
MODEL and its provider keyThe model the agents use. See agents and models.
BRIDGE_API_KEYSThe key the chat app and adapters present to the bridge. Unset, the bridge accepts the public key dev, and hubzoid doctor fails auth.bridge_keys.
WEBUI_AUTH and WEBUI_SECRET_KEYTurn on sign-in. Hubzoid refuses to start with sign-in on and no secret. Add the rest of your sign-in block from Authentication.
WEBUI_URLThe public address. OAuth callbacks are built from it, and download links fall back to it.
PORT, BRIDGE_PORTMust be unique for each hub on the machine.

Point a DNS record for the hostname at the server before you start the proxy, so the proxy can obtain a certificate.

A value in the hub's .env wins over the same variable in the process environment. If you prefer to manage settings through systemd Environment= lines or an orchestrator, leave the value out of .env.

Create the systemd unit

One template unit runs any number of hubs. Save it as /etc/systemd/system/hubzoid@.service:

/etc/systemd/system/hubzoid@.service
[Unit]
Description=Hubzoid agent %i
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=hubzoid
Group=hubzoid
WorkingDirectory=/opt/hubzoid/agents
ExecStart=/opt/hubzoid/agents/.venv/bin/hubzoid run %i
# claude-local calls the `claude` CLI, which the default systemd PATH misses.
Environment=PATH=/opt/hubzoid/.local/bin:/usr/local/bin:/usr/bin:/bin
Restart=always
RestartSec=10s
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
# Hubs keep state under agents/, and claude-local writes under ~/.claude.
ReadWritePaths=/opt/hubzoid
# ProtectHome stays off: hiding ~/.claude breaks MODEL=claude-local.
ProtectKernelTunables=true
ProtectKernelModules=true
RestrictSUIDSGID=true
LockPersonality=true

[Install]
WantedBy=multi-user.target

%i is the instance name, which is the hub folder. Start the hub and follow its log:

sudo systemctl daemon-reload
sudo systemctl enable --now hubzoid@devops-agent
journalctl -u hubzoid@devops-agent -f

Put a reverse proxy in front

Use the proxy you already operate. Whatever you choose, it must:

  • terminate TLS on 443 and redirect 80 to 443
  • forward to 127.0.0.1:<PORT> for each hub
  • pass Server-Sent Events through without buffering, because replies stream token by token
  • carry WebSocket upgrades

A Caddy configuration for two hubs:

/etc/caddy/Caddyfile
devops.agents.example.com {
    reverse_proxy 127.0.0.1:3080 {
        flush_interval -1
        transport http {
            read_timeout 600s
        }
    }
}

sales.agents.example.com {
    reverse_proxy 127.0.0.1:3081 {
        flush_interval -1
        transport http {
            read_timeout 600s
        }
    }
}

flush_interval -1 turns off response buffering and read_timeout 600s covers long replies. With nginx, the equivalent is proxy_buffering off; plus passing the Upgrade header. Managed load balancers and tunnels work too, as long as they meet the list above.

The edge keeps the incoming Host header. When WEBUI_URL or HUBZOID_PUBLIC_URL starts with https:// and the proxy sends no X-Forwarded-Proto, the edge adds X-Forwarded-Proto: https. OAuth redirects are then built from the public address.

Check the deployment

/opt/hubzoid/agents/.venv/bin/hubzoid doctor /opt/hubzoid/agents/devops-agent

Fix every fail. Then open https://devops.agents.example.com, sign in, and open /portal/ to reach the Console. The doctor reference lists every check.

Files the agent makes are served at /artifacts/... through the edge. The link's base address comes from HUBZOID_PUBLIC_URL, then WEBUI_URL, then http://127.0.0.1:<BRIDGE_PORT>. Behind a proxy, WEBUI_URL is usually enough. Set HUBZOID_PUBLIC_URL only when downloads must use a different public address.

Links are signed with a per-hub secret in .hubzoid/artifact_secret. They never expire unless you set HUBZOID_ARTIFACT_LINK_TTL in seconds.

Several hubs on one server

Repeat the .env step for each hub with its own PORT and BRIDGE_PORT, then enable another instance of the same unit:

sudo systemctl enable --now hubzoid@sales-agent

Each hub has its own chat app, user list and URL. To give several teams one sign-in and one Console instead, use a gateway.

Scheduled work

Markdown tasks in schedule/*.md run whenever their files exist (HUBZOID_DISABLE_SCHEDULE=1 turns them off). Code workflows in workflows/ are scheduled on a standalone hub only when its environment sets HUBZOID_SCHEDULES=1. A gateway turns this on for every hub. See operating runs.

Running on a Claude subscription

MODEL=claude-local runs inference through the claude CLI on a Claude Pro or Max subscription. On a server, mint a long-lived token on any machine signed in to the subscription with claude setup-token, and put it in the hub's .env as CLAUDE_CODE_OAUTH_TOKEN. Install the claude CLI as the hubzoid user so it is on the unit's PATH. The unit above already keeps the service user's home writable for the CLI's session files.

Slack and inbound channels

The Slack adapter uses Socket Mode, so it needs no public route. Run it as its own unit so a Slack problem never restarts the chat app:

hubzoid slack systemd /opt/hubzoid/agents/devops-agent \
  --python /opt/hubzoid/agents/.venv/bin/python \
  --user hubzoid \
  | sudo tee /etc/systemd/system/hubzoid-slack@devops-agent.service

hubzoid inbound systemd <hub> prints the equivalent unit for WhatsApp, Telegram and webhook surfaces. See Slack and WhatsApp and Telegram.

Troubleshooting

SymptomCheck
AnythingRun hubzoid doctor <hub> first.
Boot fails with WEBUI_AUTH=true requires WEBUI_SECRET_KEYSet WEBUI_SECRET_KEY in .env.
Boot fails with OAuth client IDs are set but WEBUI_URL is notSet WEBUI_URL=https://your.host in .env.
Scheduled tasks never run and the log says the workflow engine did not startdeps.sqlite in doctor. Use a Python build with SQLite 3.42 or newer, or PostgreSQL.
The unit starts but the site is unreachableWatch journalctl -u hubzoid@<hub> -f for the chat app's ready line, then check the proxy's logs.
Two hubs conflict at startGive each hub a unique PORT and BRIDGE_PORT.
The certificate never issuesDNS has not propagated, or the server cannot reach the certificate issuer. Check the proxy's logs.

Next steps

Read this page as Markdown

Choose which cookies Hubzoid can use. You can change this at any time from Cookie settings in the footer. Read the Cookie Policy for details.