Threadmark server, web interface, and host daemon for managing coding-agent contexts across machines. https://threadmark.rhymelikedi.me
  • JavaScript 88.5%
  • Python 4%
  • TypeScript 3%
  • CSS 2%
  • PowerShell 1%
  • Other 1.4%
Find a file
donut 1efb30cf80 feat: publish and apply hub image updates by channel
Add a Forgejo hub-image workflow, a registry-digest feed publisher, and a per-hub updater with state snapshots, idle gating, health checks and rollback. Wire the production compose to accept THREADMARK_IMAGE.
2026-10-24 14:36:01 -07:00
.forgejo/workflows feat: publish and apply hub image updates by channel 2026-10-24 14:36:01 -07:00
deploy feat: publish and apply hub image updates by channel 2026-10-24 14:36:01 -07:00
docs Track who is reading a context and hold the turn alert until it is unread 2026-10-21 16:55:10 -07:00
extension fix(hub): codex stream 413s with a reason instead of a silent 500; strip pi-internal details on the fleet wire 2026-10-19 14:03:13 -07:00
hostd Finish integrations contract, packaged runtime validation and operator docs 2026-10-19 00:51:25 -07:00
hub Track who is reading a context and hold the turn alert until it is unread 2026-10-21 16:55:10 -07:00
scripts feat: publish and apply hub image updates by channel 2026-10-24 14:36:01 -07:00
test fix(hub): retire a stale partial when Claude finalizes in place 2026-10-21 15:22:29 -07:00
.dockerignore Hub image: ship the LICENSE with it; keep the publish commands on separate lines 2026-09-27 20:05:13 -07:00
.gitattributes [self-hosting] a minimal self-host kit: hub image, compose, env and Caddy examples 2026-09-23 21:24:41 -07:00
.gitignore [self-hosting] a minimal self-host kit: hub image, compose, env and Caddy examples 2026-09-23 21:24:41 -07:00
claudeImplementationPlan.md Docs: hostd 10 on stable, desktop 0.5.0 and mobile 1.3.0 released 2026-09-24 23:46:39 -07:00
Dockerfile Hub image: ship the LICENSE with it; keep the publish commands on separate lines 2026-09-27 20:05:13 -07:00
LICENSE Threadmark License, and a hub image fit to publish 2026-09-27 20:04:39 -07:00
package.json Threadmark License, and a hub image fit to publish 2026-09-27 20:04:39 -07:00
README.md feat: publish and apply hub image updates by channel 2026-10-24 14:36:01 -07:00

Threadmark

Threadmark puts the pi coding agent on all your machines behind one hub that you run yourself. From a browser, a desktop app or your phone you can watch every pi session on every machine as it streams, prompt or steer it, start new ones on any machine, schedule prompts, and undo what an agent edited.

It is for people who already run pi (or want to) on more than one machine (a workstation, a few servers, a GPU box) and want one place to see and drive them.

This repository holds the hub, its web UI, the host daemon and the pi extensions. The desktop app and the phone app are published separately, in the threadmark-desktop and threadmark-mobile repositories (see Repositories).

Status: preview. Hosts join with join codes, get their own keys and update themselves from your hub. Per-user logins are not done yet: the web UI and the apps sign in with one hub token that can do everything, so treat it like a root password. See Limits.

Contents

Repositories

Threadmark is three repositories, published separately:

Repository What it holds Where
threadmark (this one) the hub, the web UI, the host daemon, the pi extensions <repository URL>
threadmark-desktop the desktop app (Linux, Windows, macOS) <repository URL>
threadmark-mobile the phone app (iOS, Android) <repository URL>

Placeholder: <repository URL> marks where each repository's public address goes. The addresses are not filled in yet.

You need only this one to run a hub and join hosts.

How it fits together

  web UI (browser)        desktop app          phone app
          \                    |                   /
           `--- REST /api/*, WebSocket /ws/stream -'      hub token
                               |
                    your TLS reverse proxy (Caddy)
                               |
         HUB  (Docker, published on 127.0.0.1:8321 only)
         keeps: host keys, join codes, host releases, provider sign-ins
                               ^
             WebSocket /ws/bridge, dialled OUT by each host      host key
           /                   |                     \
   host A: hostd          host B: hostd          host C: hostd
   runs pi sessions,      ...                    + pi TUIs with the
   updates itself                                  Threadmark extension
  • The hub (hub/) keeps the registry of hosts and sessions, serves the client API, the live stream and the web UI (hub/web/), and builds the code hosts run as a versioned release.
  • hostd (hostd/), the host daemon, runs on each machine. It dials out to the hub (a host needs no open port), runs the pi sessions the hub asks for, and installs new releases from the hub when no session is mid-turn.
  • The pi extensions (extension/) mirror a pi process to the hub and add a toolkit to stock pi; see extension/README.md.
  • Hosts join with a join code: a 12-character code an admin mints on the hub, single use by default (up to 20 with --uses) and short-lived, traded for the host's own key.

Quick start

You need:

  • For the hub: a Linux machine with Docker and Docker Compose v2, a DNS name pointing at it, and ports 80 and 443 open for the reverse proxy.
  • For each host: Linux with systemd, Windows 10 or 11, or macOS (with one manual step), and outbound HTTPS to the hub and to the npm registry.
  • Node 22 or later on each host. On Windows the installer installs it when it is missing. On Linux it installs the distribution's own Node, which is too old on Debian 12 and Ubuntu 24.04 (Node 18), Debian 13 (Node 20), and RHEL 9 and its rebuilds; there, install Node 22 first (step 5 shows how).
  • A model for pi: a sign-in or API key you add on each host with pi's /login (step 7), or a Codex (ChatGPT) subscription you connect once on the hub, which sessions started from the hub can use on every host.

The examples use hub.example.com; use your own name.

1. Run the hub

On the hub machine, clone this repository (its address is in Repositories), then:

git clone <repository URL> threadmark
cd threadmark
cp deploy/.env.example deploy/.env
chmod 600 deploy/.env
# edit deploy/.env: THREADMARK_PUBLIC_URL=https://hub.example.com
docker compose -f deploy/compose.yaml up -d --build

Check that it answers:

curl -s http://127.0.0.1:8321/api/health
# {"ok":true,"service":"threadmark-hub","hubVersion":4,...,"publicUrl":"https://hub.example.com",...}
docker compose -f deploy/compose.yaml logs hub

The log shows the release it built for your hosts, the admin socket and listening on 0.0.0.0:8321. That is the address inside the container; compose publishes it on 127.0.0.1 only, so the warning about all interfaces next to it is expected.

Admin commands run inside the container, through the hub's admin socket. This README writes them as hub-cli <command>; the alias makes that work:

alias hub-cli='docker compose -f /path/to/threadmark/deploy/compose.yaml exec hub node hub/cli.mjs'
hub-cli --help

2. Put it behind TLS

The hub listens on 127.0.0.1 only, so a reverse proxy on the same machine is the way in. With Caddy, copy the site block from deploy/Caddyfile.example into your Caddyfile (often /etc/caddy/Caddyfile), replace hub.example.com, and reload Caddy (sudo systemctl reload caddy). Caddy gets the certificate by itself.

curl -s https://hub.example.com/api/health

The example passes /api/* and /ws/* straight through and puts the web UI behind the proxy; its header lists what any other proxy must do.

Then tell the hub to believe the proxy's X-Forwarded-For, so its rate limits and audit log see real client addresses (without it, ten mistyped join codes lock everyone out of joining for 10 minutes):

docker network inspect threadmark-hub_default --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
# put that address in deploy/.env:  THREADMARK_TRUST_PROXY=<address>
docker compose -f deploy/compose.yaml up -d

3. Open the web UI

Browse to https://hub.example.com. It asks for the hub token, once per browser:

docker compose -f deploy/compose.yaml exec hub cat /data/token

The hub generated it at its first start. The browser keeps it in local storage. To sign in with a proxy login instead, see THREADMARK_UI_TOKEN under Configuration.

4. Get a join code

In the logged-in web UI, open the server menu, choose Add a host, optionally enter the host id, and click Generate install code. The hub creates a one-use code and shows complete Linux and Windows install lines with the code already filled in. Nothing has to be run on the hub.

For automation or break-glass administration, hub-cli join-code prints the same code and lines. A code is single use by default (up to 20 with --uses) and expires in 30 minutes. CLI options:

Option Meaning
--host ID the host id the code joins (default: the machine's hostname); letters, digits, ., _, -
--name N the display name
--uses N up to 20 machines with one code (only without --host)
--ttl 2h how long it lives: 90s, 30m, 2h, 1d; 60 s to 24 h
--channel canary the release channel the host follows (default stable)

The code itself is stored only as a hash and is shown once. Generate another if the first expires or has already been used.

5. Join hosts

Run the line on the machine as the user pi and hostd should run as, not under sudo. The code goes in the environment, never on the command line, so it never shows in a process list.

Linux (systemd). First check the host's Node:

node --version

If it prints v22 or later, go on to the join line. If Node is missing or older, the installer installs the distribution's own nodejs package (with apt, dnf, yum, pacman, zypper, apk or xbps). When Node, the global Pi CLI, or an existing system service needs root, the installer asks for sudo once at the terminal and uses that cached authorization for the privileged commands. That package is too old on several common systems: Node 18 on Debian 12 and Ubuntu 24.04, Node 20 on Debian 13, and older still on RHEL 9 and its rebuilds (Rocky, AlmaLinux). There the installer stops with node on this host is still older than 22, so install Node 22 or later first. From NodeSource:

# Debian, Ubuntu
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs

# RHEL 9, Rocky, AlmaLinux
curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
sudo dnf install -y nodejs

or, on RHEL 9 and its rebuilds, from the distribution's newer module stream where your release has one (dnf module list nodejs lists them):

sudo dnf module reset -y nodejs
sudo dnf module install -y nodejs:22

Check node --version again, then run the join line:

curl -fsSL https://hub.example.com/api/v1/install.sh | THREADMARK_JOIN=XXXX-XXXX-XXXX sh -s --

It checks everything it will change before it changes anything, trades the code for this host's key (~/.threadmark/host.json, mode 0600), installs the release under ~/.threadmark/app/, installs the pi CLI (using sudo when npm's global directory requires it), downloads the hub-managed Pi profile, and starts hostd as the systemd user service threadmark-hostd, with lingering on so it runs from boot. The profile contains the fleet's settings, custom models, MCP configuration, agents/prompts and optional extensions; local sessions and Pi login state are never copied. It ends with:

joined as <host id>, release <release>, updates from https://hub.example.com

Windows (PowerShell, as your normal user):

$env:THREADMARK_JOIN='XXXX-XXXX-XXXX'; iwr -useb https://hub.example.com/api/v1/install.ps1 | iex

It installs Node LTS with winget (else Chocolatey) if Node is missing; an older Node stops it with node at … is older than 22, so upgrade that first (winget upgrade OpenJS.NodeJS.LTS). It keeps hostd running with the scheduled task threadmark-keepalive (hidden, checked every minute). The task runs while you are signed in.

macOS has no service installer yet. Install Node 22 or later first (for example brew install node), then join without the service:

curl -fsSL https://hub.example.com/api/v1/install.sh | THREADMARK_JOIN=XXXX-XXXX-XXXX sh -s -- --no-service

and keep node ~/.threadmark/app/launcher.mjs running under a supervisor that restarts it whenever it exits (hostd exits to apply an update), such as a launchd agent with KeepAlive, or a terminal you leave open to try it out.

Installer options go after sh -s -- on Linux and macOS:

Option Windows Meaning
--host ID, --name N -HostId, -Name ask for this host id and display name (a code bound to an id decides the id)
--dry-run -DryRun print the plan and run the full preflight against the hub; changes nothing. On a machine that has not joined yet, THREADMARK_JOIN must still be set, as for the real run: the dry run checks that a code is there, but does not send it to the hub or use it up (so the placeholder XXXX-XXXX-XXXX works too)
--no-service -NoService install the release, leave the service alone
--force -Force restart hostd at once, even while a session is mid-turn (by default it waits)
--insecure-http -InsecureHttp allow a plain http:// hub that is not on loopback

iwr | iex takes no options; on Windows, save the script and run it instead (the code stays in $env:THREADMARK_JOIN):

iwr -useb https://hub.example.com/api/v1/install.ps1 -OutFile threadmark-install.ps1
powershell -ExecutionPolicy Bypass -File .\threadmark-install.ps1 -DryRun

Run the same line without a code on a joined host to repair it (it re-verifies and re-activates the current release). Exit codes: 0 done, 1 something to fix first, 2 the preflight failed and nothing changed (it prints what to run instead), 3 the release could not be installed, 4 the service could not be installed or the host did not come online.

The host shows in hub-cli hosts list and in the web UI within seconds.

6. Connect the apps

Desktop (Linux, Windows, macOS): download it from https://updates.rhymelikedi.me/threadmark/desktop/, the official download page. Start it, enter https://hub.example.com and the hub token. It updates itself from the same page. The builds are previews: the Windows installer is unsigned and the macOS app is ad-hoc signed, so both systems warn before the first start. Its source is the threadmark-desktop repository (Repositories).

Phone (iOS, Android): there is no public build; the maintainer's own builds go out by invitation only. Build your own from the threadmark-mobile repository (Repositories; Flutter, and its README has the steps), then enter the hub's address and token.

To find the address and token, open Settings → Devices in the web UI (the server menu's Sign in the desktop or phone app shows the same), or use Fleet → Sign in a device in the desktop app (0.5.0 and later). Both show the address and the token, with the token hidden until you choose Show, and a Copy button for each.

Add a host in the web UI and in the desktop app (0.5.0 and later) generates the one-use join code for you and shows both install lines with it filled in. Older desktop builds and the phone app still show a join line without a code, which stops with "ask your hub admin for a join code"; use the lines from hub-cli join-code there.

7. Give the hosts models and tools

Settings in the web UI holds everything that configures the hub: subscription usage, provider sign-ins and OpenAI-compatible endpoints (LiteLLM, vLLM, Ollama), hosts, signing in the apps, and appearance. An endpoint added there, or one the Pi profile's models.json already lists, has its models read on Refresh all models and every 15 minutes, and every hub-mode host receives them.

The hub is the source of the fleet Pi profile. Set THREADMARK_PROFILE_DIR to an agent/ directory, or (during migration) configure THREADMARK_REPO and the hub uses that clone's agent/ directory. On enrollment and then periodically, each keyed host receives settings.json, models.json, mcp.json, agents, prompts, skills, themes and optional extensions. The hub-built release supplies the fleet bridge, toolkit, MCP bridge and web-search extension itself. Profile files are owner-only on the host; auth.json, model login state, session files and binaries are never distributed.

In desktop, Settings → Tools & integrations → Manage integrations configures hub-owned MCP servers and the search service. MCP servers are On demand or Default on; every default is overridable per provider, context or routine. Legacy always selections become Default on without changing credentials. Profile definitions remain read-only, but their activation mode can be changed. See MCP and web-search configuration for inheritance, private host synchronization, security and rollout details.

For web search, configure a SearXNG base URL in that manager, reachable by the hub. Alternatively, set THREADMARK_WEBSEARCH_URL; it takes precedence and locks the URL in the editor, while leaving the default enable switch editable. Hosts call the authenticated hub route, so they do not need a network path to SearXNG or its address. The hub caches successful searches for two minutes, coalesces concurrent identical queries, and caps upstream work at four searches. Partial engine failures appear as warnings; an empty response with engine failures is HTTP 503, not a successful “no results.” The upstream 20-second deadline includes reading the response. Pi reports failures as tool errors and labels cached results with their fetch time.

/api/health includes websearch: {configured, state, checkedAt} from the last upstream attempt (unknown, ok, degraded, or unavailable; unconfigured when no valid URL is set). It does not make a search itself. Monitor SearXNG with a real query as well as checking that the process is alive.

Share a Codex (ChatGPT) subscription from the hub. In the web UI open Providers, choose Codex subscription and sign in with the device code it shows. The credential stays on the hub. Its models appear as fleet-codex/<model> in hostd sessions and in ordinary pi TUIs on every enrolled host; the host authenticates with its own key and never stores the subscription credential.

A machine may still keep personal providers in its local ~/.pi/agent/auth.json; that local file is deliberately outside the hub profile.

Day to day

Working with sessions

The web UI lists your hosts and their sessions ("contexts"). Open one to read it live and prompt it; while a turn runs you can steer it or queue a follow-up, or abort it. New context starts a session on any host with a hostd online, in the working directory you pick. From a session you can switch its model and thinking level, compact it, fork it from any message, undo the file edits of a turn (on hosts with a current hostd), rename, archive, export and delete it. Routines are prompts the hub runs on a schedule (every N minutes, cron, or once) on a host you choose. Add fallback models in the routine editor to try alternatives in order if a model/provider fails (requires hostd v12+). Recovery keeps the conversation and completed tool results, including when switching between pi providers and Claude Code. Runs show their actual model, outcome and attempt history. Claude → pi recovery requires explicit full-access approvals; it never bypasses Claude's approval policy automatically.

Fallbacks improve reliability, not guarantee success: all providers may fail. User aborts, approval waits, tool errors and uncertain interruptions do not trigger automatic restarts. After a daemon restart, inspect the interrupted run before resuming it; external side effects still need idempotent tools.

Adding and removing hosts

hub-cli hosts list                           # status, online, auth, release, target, channel
hub-cli hosts set <id> --name "Build box"    # display name
hub-cli hosts set <id> --status disabled     # keep the entry, refuse its key (active to undo)
hub-cli hosts revoke <id>                    # revoke its keys and close its connections now

A revoked host stops at once and retries every 15 minutes. To join it again, mint a code for the same id (hub-cli join-code --host <id>) and run the line. A host that lost its ~/.threadmark/host.json is joined the same way, after hub-cli hosts revoke <id>.

To take a Linux host out for good: hub-cli hosts revoke <id>, then on the host

systemctl --user disable --now threadmark-hostd
rm ~/.config/systemd/user/threadmark-hostd.service && systemctl --user daemon-reload
rm ~/.pi/agent/extensions/{fleet-bridge,fleet-codex,fleet-toolkit,mcp-bridge,websearch}.ts
rm -rf ~/.threadmark

On Windows, remove the threadmark-keepalive scheduled task, end the node process running launcher.mjs, and delete the same files under your profile.

Releases, channels and updates

The hub builds the code hosts run from its own copy of hostd/, extension/ and docs/stock-pi-toolkit.md, at every start and on hub-cli release rebuild. A release's id depends only on those files, so an unchanged tree builds nothing new. Hosts follow a channel, stable or canary; when their target changes, the hub tells them, and each host downloads and verifies the release, waits until no session on it is mid-turn, and restarts onto it. A new release that keeps failing to start (three starts in 10 minutes without reaching the hub) is rolled back on that host by itself.

To update your hub and, through it, your hosts:

git pull
docker compose -f deploy/compose.yaml up -d --build

The hub image itself can follow a channel feed like the desktop app: .forgejo/workflows/hub-image.yml builds and pushes canary (or stable from a hub-v* tag), scripts/publish-hub-feed.mjs publishes the registry digest to /srv/updates/threadmark/hub/<channel>.json, and deploy/hub-updater/ runs on each hub to pull the exact digest, snapshot state, health-check and roll back on failure. See deploy/hub-updater/README.md.

With the default release policy (auto) a new build goes to both channels at once. With THREADMARK_RELEASE_POLICY=manual it goes to canary only, and you promote it when it has proved itself:

hub-cli release list                                 # releases, channels, which hosts run what
hub-cli hosts set <id> --channel canary              # let one host try new builds first
hub-cli release promote <release id>                 # move stable to it
hub-cli release pin <id> <release id>                # keep one host on a release
hub-cli hosts set <id> --unpin                       # follow its channel again
hub-cli hosts set <id> --updates off                 # pause updates for a host (hub to resume)
hub-cli hosts apply-update <id>                      # ask it to update now (still waits for idle)

hub-cli hosts apply-update <id> --force skips the wait and ends any turn in progress on that host.

Naming sessions

Sessions start unnamed. Give the hub an OpenAI-compatible endpoint and it names each session after its first prompt: set NAMED_API_BASE, NAMED_API_KEY and NAMED_MODEL (optionally NAMED_MODEL_FALLBACKS) in deploy/.env and run docker compose -f deploy/compose.yaml up -d. A session is named once; the request carries its prompt and the start of the reply. THREADMARK_STEP_TITLES=1 also titles each finished run of tool calls, which sends the calls' paths and commands too (docs/api.md, "Naming and step titles").

Backups

Everything the hub keeps is in the threadmark-hub_data volume. Losing it means joining every host again. The backup holds the hub token and provider sign-ins, so keep it private:

docker compose -f deploy/compose.yaml stop hub
docker run --rm -v threadmark-hub_data:/data:ro -v "$PWD:/backup" alpine tar czf /backup/threadmark-hub-data.tgz -C /data .
docker compose -f deploy/compose.yaml start hub

To restore a backup: stop the hub, empty the volume and unpack the backup into it, keep the files owned by uid 1000 (the image's node user, which the hub runs as), and start the hub. From the directory that holds threadmark-hub-data.tgz:

docker compose -f deploy/compose.yaml stop hub
docker run --rm -v threadmark-hub_data:/data -v "$PWD:/backup:ro" alpine \
  sh -c 'find /data -mindepth 1 -delete && tar xzf /backup/threadmark-hub-data.tgz -C /data && chown -R 1000:1000 /data'
docker compose -f deploy/compose.yaml start hub

The restored hub has its old identity, host keys and token, so hosts, browsers and apps reconnect without joining or signing in again. To restore onto a new machine, do steps 1 and 2 of the Quick start there first (the first start creates the volume), then restore as above and point the hub's DNS name at the new machine.

Audit log

hub-cli audit tail                  # code mints, joins, revokes, releases, admin calls
hub-cli audit tail --type host.     # one kind of entry

Configuration

The hub is configured with environment variables; with the kit, lines in deploy/.env (then docker compose -f deploy/compose.yaml up -d). Everything else lives in the hub's state and changes through hub-cli or the web UI.

Variable Default Meaning
THREADMARK_PUBLIC_URL none (required) The https address clients and hosts reach the hub at, such as https://hub.example.com, with no path: the hub answers only at the root of its name, and the web UI builds its paths from the root. Every URL the hub hands out is built from it. Without it the hub cannot mint codes or serve installers.
THREADMARK_TRUST_PROXY none Proxies whose X-Forwarded-For / CF-Connecting-IP the hub believes: loopback, CIDRs and/or cloudflare, comma-separated.
THREADMARK_RELEASE_POLICY auto auto: a new build goes to stable and canary. manual: to canary only.
THREADMARK_UI_TOKEN off 1 serves the hub token at /ui/token, so the web UI signs itself in. Only behind a proxy login on the web UI (the basic_auth block in the Caddyfile example): anyone who reaches /ui/token gets the token.
THREADMARK_TOKEN generated The hub token. Unset, the hub generates one into STATE_DIR/token at its first start.
THREADMARK_LEGACY full What the hub token may do: full, hosts-only, off. The web UI and the apps need full for now.
NAMED_API_BASE, NAMED_API_KEY, NAMED_MODEL none Session naming through an OpenAI-compatible endpoint (all three needed).
NAMED_MODEL_FALLBACKS none More models to try, comma-separated.
THREADMARK_STEP_TITLES off 1 titles finished runs of tool calls (needs naming).
PORT 8321 The port the hub listens on (HUB_PORT is accepted too).
THREADMARK_BIND 0.0.0.0 The address it listens on. Outside Docker, use 127.0.0.1.
STATE_DIR hub/state; /data in the image Where the hub keeps everything.
THREADMARK_RELEASE_SRC the directory above hub/ The tree host releases are built from.
THREADMARK_SHA none Recorded in each release's manifest (a build argument of the image). Pass it from the shell when you build: THREADMARK_SHA=$(git rev-parse HEAD) docker compose -f deploy/compose.yaml up -d --build.
THREADMARK_REPO, DOTFILES_BRANCH, REPO_POLL_MS none, main, 15000 An optional git repository whose threadmark.json adds hosts and naming settings, polled for new commits. Hosts that join with a code do not need it.
HEARTBEAT_TIMEOUT_MS, SESSION_GRACE_MS 45000, 35000 When a silent host counts as offline; how long a disconnected TUI's session survives a reconnect.

The compose file also reads THREADMARK_PORT (the loopback port it publishes, default 8321) and THREADMARK_IMAGE (the image tag). In the container, STATE_DIR, PORT and THREADMARK_BIND are fixed.

On a host, ~/.threadmark/host.json holds its identity and key, written by the installer. THREADMARK_HUB, THREADMARK_HOST_ID and THREADMARK_NAME override it for hostd, and PI_DIR moves pi's directory (default ~/.pi). pi's sign-ins, which hostd's sessions use, are in ~/.pi/agent/auth.json (step 7).

Security model

  • The hub is trusted with everything. Hosts do what the hub asks: that is the product. Whoever controls the hub, or its token, can run commands on every host. Keep it patched, behind TLS, and back up its volume.
  • TLS is required. Whoever can tamper with a plain-http connection to the hub can change what a joining host installs and drive every host. The installers refuse a non-loopback http:// hub unless given --insecure-http.
  • Hosts have their own keys. A join code is 60 bits, single use by default (up to 20 with --uses), and expires in 30 minutes by default (24 hours at most). Codes are minted only on the admin socket, inside the hub's container, so reaching the admin socket (for example with docker compose exec) is admin access. The code is traded for a host key the hub stores only as a hash. The key travels only in an Authorization header, opens only that host's own connection, releases and the provider proxy, and is revoked at once with hosts revoke. Joining is rate-limited: 10 failures per address in 10 minutes, 100 overall.
  • The hub token is a master key. The web UI and the apps use it until per-user logins land. It can read every transcript and prompt every session. Keep it out of shared machines and chats. To replace it (unless you set THREADMARK_TOKEN, then change that), have the hub generate a new one: docker compose -f deploy/compose.yaml exec hub rm /data/token, then docker compose -f deploy/compose.yaml restart hub. Every browser and app then asks for the new one.
  • Hosts dial out. No host opens a port. hostd's local pi server is off while a hub is configured, and listens on loopback only when turned on.
  • A host's key is readable by agents on it. They run as the same user. A stolen key speaks for that one host, not the fleet; revoke it and join again.
  • What leaves the hub. Provider sign-ins (the Codex subscription) stay on the hub. Session naming, if you turn it on, sends prompts to your naming endpoint.
  • Not done yet: per-user logins and roles, per-device tokens for the apps, signed releases (releases are verified by sha256 over the TLS connection).

What each phase of the self-hosting work adds is in docs/self-hosting-plan.md: the maintainer's design notes, written around the maintainer's own fleet (its migration runbook is in docs/maintainers.md).

Troubleshooting

Symptom What to do
hub-cli join-code answers 409 set THREADMARK_PUBLIC_URL, or the join line fails with HTTP 503 THREADMARK_PUBLIC_URL is missing or was refused (the hub log says why; it must be a plain http(s) address, no user, query or fragment). Fix it in deploy/.env and docker compose -f deploy/compose.yaml up -d.
this hub's public address is …; run the line with --hub … The line used another address than THREADMARK_PUBLIC_URL; use the lines hub-cli join-code prints.
node on this host is still older than 22 (Linux) The distribution's own Node is too old: Node 18 on Debian 12 and Ubuntu 24.04, 20 on Debian 13, older on RHEL 9 and its rebuilds. Install Node 22 or later from NodeSource or the distribution's newer stream (step 5 has the commands), then run the same line again; the code was not used.
node at … is older than 22 (Windows) Upgrade Node (winget upgrade OpenJS.NodeJS.LTS), then run the same line again; the code was not used.
invalid or expired code The code is mistyped, used up (one use by default) or expired; mint another.
host id taken That id already has a key. Revoke it first (hub-cli hosts revoke <id>) or join under another --host.
too many attempts 10 failed codes from one address in 10 minutes. Wait, and set THREADMARK_TRUST_PROXY so the hub does not count every client as the proxy.
preflight failed, and nothing was changed (exit 2) Nothing was changed and the code was not used. Run the command it prints.
the hub has no release for <id> yet (exit 3) With manual releases, promote one (hub-cli release list, hub-cli release promote <id>), then run the line again without a code.
A Linux host goes offline when you log out Lingering is off: sudo loginctl enable-linger $USER.
hosts list says update waiting on N busy session(s) The host waits until no session is mid-turn; hub-cli hosts apply-update <id> --force does not wait.
A host's model picker is empty, or a session says No API key found hostd has no sign-in on that host. Sign in with pi's /login as the user hostd runs as (step 7); a key exported in a shell profile does not reach hostd.
The web UI keeps asking for the token The one you pasted is not the hub's; read it again: docker compose -f deploy/compose.yaml exec hub cat /data/token.
Nothing works through the proxy Check curl -s http://127.0.0.1:8321/api/health on the hub machine first, then the proxy's log.

Logs:

  • Hub: docker compose -f deploy/compose.yaml logs -f hub.
  • Linux host: journalctl --user -u threadmark-hostd -f.
  • Windows host: ~\.threadmark\logs\hostd.log and ~\.threadmark\keepalive.log.
  • An install started from a hub session runs in the background; it prints the path of its log under ~/.threadmark/logs/.

Limits

  • Per-user logins, roles and device pairing are not done: one hub token signs in the web UI and the apps.
  • Join codes are minted with hub-cli only, not from the web UI or the apps.
  • macOS hosts need their own supervisor (--no-service).
  • Windows hosts run hostd while their user is signed in, and restart it within a minute.
  • pi TUIs you start yourself on a joined host are not mirrored out of the box, and do not get the hub's Codex models (fleet-codex): in this version only hostd uses the host key, so only sessions started from the hub have them. extension/README.md, "Mirroring your own pi TUIs today", has the interim way.
  • Of the providers the hub can hold, only the Codex subscription reaches the hosts; other models come from pi's own sign-ins and configuration on each host (step 7).
  • The desktop app is a preview build (unsigned on Windows, ad-hoc signed on macOS). There is no public phone build.

Development

The web UI is plain files in hub/web/ (no build step); the hub and hostd are plain Node modules. Node 22 or later.

npm install                          # hub and hostd dependencies (npm workspaces)
node test/run.mjs                    # the whole suite: hub scenarios and standalone probes
node test/run.mjs S12 W7             # some steps, by id or name; -v dumps a failing hub's log
node --test test/fleet-features.test.mjs
node test/ui-demo.mjs                # a local hub with fake hosts streaming, for the web UI
node scripts/lock-hostd.mjs --check && node scripts/lock-hostd.mjs --hub --check

Run a hub straight from the checkout (state in hub/state):

THREADMARK_PUBLIC_URL=http://127.0.0.1:8321 THREADMARK_BIND=127.0.0.1 node hub/server.mjs
node hub/cli.mjs join-code          # same machine, same STATE_DIR

Build the image without compose: docker build -t threadmark-hub:local ..

  • A change to what hosts run is a change to hostd/, extension/ or docs/stock-pi-toolkit.md: the hub turns it into a new release. Build hubs from a checkout with LF line endings (the release id is a hash of the files' bytes).
  • hostd/package-lock.json ships in every release and hub/package-lock.json feeds the image; after changing either package.json, run node scripts/lock-hostd.mjs (add --hub for the hub's).
  • A wire change starts in docs/protocol-v4.md (enrollment, keys, releases, admin) or docs/api.md (client API, bridge protocol). Deploy the hub before the hosts.

Documentation

  • docs/api.md: the client API and the bridge protocol.
  • docs/protocol-v4.md: join codes, host keys, releases, the admin API and hub-cli, to the letter.
  • docs/self-hosting-plan.md: the maintainer's design notes for self-hosting and its phases, what is done and what is next, written around the maintainer's own fleet (its migration runbook is in docs/maintainers.md). Not needed to run a hub.
  • docs/stock-pi-toolkit.md: the pi toolkit and the shared Codex provider.
  • docs/PLAN-context-ux.md: the maintainer's working plan, a checklist across the three repositories, for the clients' context views, providers and one-line onboarding. Not needed to run a hub.
  • extension/README.md: the pi extensions.
  • deploy/: the self-host kit (compose.yaml, .env.example, Caddyfile.example) and the Dockerfile.

License

Threadmark is free to download and run, for yourself or your organisation, under the Threadmark License in LICENSE: no copying beyond installing it, no modifying and no redistributing without the copyright holder's written permission.

For the maintainer

How the maintainer's own deployment and release pipeline work is in docs/maintainers.md. You do not need it to run Threadmark.

Claude Code

Hostd 10 can run contexts using the host user's installed Claude Code and Claude subscription. The grouped model picker supports switching a conversation between pi and Claude, with a preview, approvals, usage limits and linked child contexts. See engines and Claude Code for Linux setup and troubleshooting.

Connected model catalogs refresh every 15 minutes, including OpenAI subscription models and configured local endpoints. Models an endpoint no longer lists are removed. Hostd also keeps Claude Code on its newest release, because new Claude models ship with it. Providers → Refresh models requests an immediate update. See model catalog refresh. Windows support is deferred.