# Self-hosting a shared hubd for a team By default `hubd` runs locally over stdio: one owner, one machine, agents spawn it on demand. To let a **team** point their agents at one shared hub, run the daemon in HTTP mode — it speaks MCP over Streamable HTTP (JSON-RPC over POST), token-gated, zero dependencies. > Status: experimental. Single-tenant by default, or multi-tenant (a workspace per token, below). Either way the > server stores and reads the team's tasks and journal — it is **not** the > end-to-end model. Review the security notes before putting sensitive work in it. ## Run the server ```bash npm i -g @bzdos/hubd # generate a secret (keep it private) export HUBD_TOKEN="$(openssl rand -hex 24)" # data lives here — back it up; it's just markdown + jsonl export HUBD_DIR=/opt/hubd-team # bind localhost and put TLS in front (recommended), or HUBD_HTTP_HOST=0.0.0.0 to expose directly hubd --http 8787 ``` Run it under a process manager (systemd, pm2, tmux, or rc.d on FreeBSD, below) so it survives restarts. The daemon refuses to start without `HUBD_TOKEN` of at least 16 characters. ### FreeBSD: rc.d and daemon(8) daemon(8) supervises the server, and an rc.d script starts it at boot. Install node and the package, add a user and a data directory, and keep the secret in a file only root reads (`/etc/rc.conf` is readable by every user): ```sh pkg install node npm && npm i -g @bzdos/hubd pw useradd hubd -d /var/db/hubd -s /usr/sbin/nologin -c "hubd server" install -d -o hubd -g hubd -m 700 /var/db/hubd install -m 600 /dev/null /usr/local/etc/hubd.env echo "HUBD_TOKEN=$(openssl rand -hex 24)" > /usr/local/etc/hubd.env # or HUBD_MULTITENANT=1 ``` `/usr/local/etc/rc.d/hubd`, mode 755: ```sh #!/bin/sh # # PROVIDE: hubd # REQUIRE: LOGIN # KEYWORD: shutdown # # hubd_enable YES to start at boot # hubd_user the account the server runs as (hubd) # hubd_dir HUBD_DIR, where the data lives (/var/db/hubd) # hubd_port the port on 127.0.0.1 (8787) # hubd_env_file read before start: HUBD_TOKEN, or HUBD_MULTITENANT=1 . /etc/rc.subr name=hubd rcvar=hubd_enable load_rc_config $name : ${hubd_enable:=NO} : ${hubd_user:=hubd} : ${hubd_dir:=/var/db/hubd} : ${hubd_port:=8787} : ${hubd_env_file:=/usr/local/etc/hubd.env} pidfile=/var/run/${name}/${name}.pid command=/usr/sbin/daemon command_args="-f -r -R 5 -S -T ${name} -P ${pidfile} \ /usr/bin/env HUBD_DIR=${hubd_dir} /usr/local/bin/node /usr/local/bin/hubd --http ${hubd_port}" start_precmd="install -d -o ${hubd_user} -m 755 /var/run/${name}" run_rc_command "$1" ``` ```sh sysrc hubd_enable=YES && service hubd start fetch -qo - http://127.0.0.1:8787/healthz ``` - `HUBD_DIR` is set in the script on purpose. rc starts a service with `HOME=/`, and without it the server makes its hub in `/.hubd`, not in `hubd_dir`. - rc.subr runs the command as `hubd_user`, so daemon(8) runs as that user too, and its pidfile goes in a directory that user owns. `-P` writes the pid of daemon(8) itself, the process `service hubd status` and `stop` look for; with `-p` the file would hold node's pid and they would not recognize it. `-r -R 5` starts node again 5 seconds after it exits; `stop` ends both. - Output goes to syslog under the tag `hubd` (`/var/log/messages`). - Upgrade: back up `/var/db/hubd`, then `npm i -g @bzdos/hubd@` and `service hubd restart`. - TLS: `pkg install caddy`, the Caddyfile from [TLS is required](#tls-is-required) in `/usr/local/etc/caddy/Caddyfile`, then `sysrc caddy_enable=YES && service caddy start`. The server stays on 127.0.0.1, the `HUBD_HTTP_HOST` default. ## Multi-tenant: a workspace per token To host one server for many independent teams (this is how hubd.net runs), set `HUBD_MULTITENANT=1` instead of a fixed `HUBD_TOKEN`: ```bash export HUBD_DIR=/var/lib/hubd # tenants live under $HUBD_DIR/tenants/ HUBD_MULTITENANT=1 hubd --http 8787 ``` Now **every Bearer token is its own isolated workspace** — stored at `tenants//`, created on the first request. No signup, no accounts: the token *is* the key. Onboarding is one line that mints a strong token client-side, so nobody has to invent one: ```bash claude mcp add --transport http hubd https://mcp.hubd.net \ --header "Authorization: Bearer $(uuidgen)" ``` Share that exact line to bring teammates into the same workspace (same token → same hub). Because the token is the only credential, it must be high-entropy — a uuid is ideal; a short or guessable token is a guessable workspace (the server rejects anything under 16 chars, but that is a floor, not real strength). ### TLS is required A bearer token over plain HTTP is a leaked token. Terminate TLS in front of the daemon. With [Caddy](https://caddyserver.com) it is one line: ``` mcp.hubd.net { reverse_proxy 127.0.0.1:8787 } ``` Caddy provisions HTTPS automatically. Expose only 443; keep 8787 internal. Health check (no auth): `curl https://mcp.hubd.net/healthz`. ## Connect an agent Each teammate adds the remote MCP server (Claude Code shown; other clients take a URL + `Authorization` header in their MCP config): ```bash claude mcp add --transport http hubd https://mcp.hubd.net/ \ --header "Authorization: Bearer $HUBD_TOKEN" ``` Agents then create work with `hub_task_add`, read the shared backlog with `hub_task_list`, and log to the shared journal with `hub_report` — a shared task intake for agents, over the network. ## What changes in HTTP mode - **`hub_sync`, `hub_queue_wait` and `hub_queue_wait_all` are disabled.** `hub_sync` reads an arbitrary filesystem path and runs `git` on the host — safe locally, a hole on a shared server. The waits hold a connection open for minutes — a resource-exhaustion vector there. `hub_queue_send` still works (delivery is files); consumers wait on a machine that has the hub locally. - **No `HUBD_AGENT` floor.** The floor names whoever configured the server — over HTTP that is the operator, not any of the many callers, so it does not apply: every caller passes `agent` / `by` / `from` explicitly (the tool schemas require them). For the same reason `hub_whatsnew` checkpoints key on the agent name, and the `environment` list never reports the server's own env to a remote caller. - **Nothing reads the server's own disk on a caller's behalf.** A `cwd` or a path a remote caller passes names a place on ITS machine, so `hub_context` resolves the project from hub data only (recorded sync paths, card names — no `.hubd` marker lookup, no git-root walk) and reports `claimsTouched` as not checked; `hub_claim_check` needs `project`; `hub_presence` filtered by project and the audit riding on `hub_brief` / `hub_whatsnew` skip the same walks (the audit's commits-without-journal check runs `git` in a card's `- path:`, which a tenant can write). The server sets this itself; a caller cannot turn it off. - Every other tool (tasks, journal, status, search, brief, claims, cards, resources, graph) behaves exactly as over stdio — the transport is the only difference. - The board (`hub serve` in multi-tenant mode, `?t=`) reads a tenant's own queues for its "waiting for you" block, never the operator's. ## Environment | Variable | Default | What it does | |---|---|---| | `HUBD_TOKEN` | — | single-tenant bearer secret (≥16 chars; required unless multi-tenant) | | `HUBD_MULTITENANT` | off | `1` → every token is its own workspace at `tenants//` | | `HUBD_DIR` | `~/.hubd` | where data lives | | `HUBD_TEAM_DIR` | = `HUBD_DIR` | queues and `AGENTS.md`; set alone it is the data dir too (one directory) | | `HUBD_NODE` | the hostname | this node's name in its per-node files (`journal..jsonl`, `tasks..events.jsonl`, queue shards); `mesh-sync.sh` commits under it too, so set it where the sync runs as well | | `HUBD_SUBSCRIBER` | — | stdio only: the reader name for broadcast-role cursors (else `HUBD_SESSION`, else `HUBD_AGENT`, else the client process) | | `HUBD_TASK_ID_PREFIX` | the node name | prefix of new task ids (`-`), when the hostname should not appear in them | | `HUBD_SENSE_DIR` | `/.sense` | where `hub sense` keeps each head's sensor state (node-local) | | `HUBD_SENSE_ESCALATIONS` | `/escalations.log` | the file `hub sense` appends escalations to (`\t\t`); point it at your monitor's file (its directory is created; a line that cannot be written is reported, not dropped) | | `HUBD_HTTP_HOST` | `127.0.0.1` | bind address (keep it localhost behind a TLS proxy) | | `HUBD_HTTP_PORT` | `8787` | port (or pass `--http `) | | `HUBD_RATE_LIMIT` | `120` | max POSTs per minute per client IP (over the limit → 429) | | `HUBD_MAX_TENANTS` | `1000` | cap on new tenant creation in multi-tenant mode (over the cap → 403) | ## The hub directory is data, not code `HUBD_DIR` holds only your data — cards, journal, tasks. The code comes from the installed package; don't copy hubd's source into the hub directory. Keeping the two separate means `npm update -g @bzdos/hubd` upgrades the code without touching your data, and the data folder stays a clean thing you can back up or open in any Markdown tool (see [interop](interop.md)). ## Security notes - **One token = full access** (single-tenant) or **one token = one workspace** (multi-tenant). Anyone holding a token can read and write that hub — treat it like a key. Rotate a single-tenant secret by changing `HUBD_TOKEN` and restarting. - **Not end-to-end.** The server reads the data. Trust in the host is the model here; an end-to-end personal mode is on the roadmap, separately. - **Abuse guards are built in.** A per-IP request rate limit (`HUBD_RATE_LIMIT`) and a cap on tenant creation (`HUBD_MAX_TENANTS`) bound the obvious disk-fill and flood vectors. Still: put it behind TLS and harden before exposing widely — there is no audit log yet. - **The rate limit keys on `X-Forwarded-For` when it is present.** That is right behind a TLS proxy that sets it, and spoofable when clients reach the server directly — keep `HUBD_HTTP_HOST` on localhost behind the proxy. - **Upgrade a public server before anything else.** Versions before 0.9.25 let a queue role carry a path (`../../x`), which wrote a file outside the hub wherever the server user could write, and resolved a caller's `cwd` against the server's own disk. - Back up `HUBD_DIR`; it is plain files and git-friendly.