- JavaScript 88.5%
- Python 4%
- TypeScript 3%
- CSS 2%
- PowerShell 1%
- Other 1.4%
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. |
||
|---|---|---|
| .forgejo/workflows | ||
| deploy | ||
| docs | ||
| extension | ||
| hostd | ||
| hub | ||
| scripts | ||
| test | ||
| .dockerignore | ||
| .gitattributes | ||
| .gitignore | ||
| claudeImplementationPlan.md | ||
| Dockerfile | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
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
- How it fits together
- Quick start
- Day to day
- Configuration
- Security model
- Troubleshooting
- Limits
- Development
- Documentation
- License
- For the maintainer
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 withdocker 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 anAuthorizationheader, opens only that host's own connection, releases and the provider proxy, and is revoked at once withhosts 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, thendocker 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.logand~\.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-clionly, 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/ordocs/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.jsonships in every release andhub/package-lock.jsonfeeds the image; after changing eitherpackage.json, runnode scripts/lock-hostd.mjs(add--hubfor 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.