Skip to Content

Pool two Hubs

Pair two Companion Hubs over your tailnet so an app on one can be answered by the other, then prove it with a model that exists on only one of them.

Time: ~25 minutes, plus the model download · You’ll need: two Hubs running Companion Hub 0.3.0, both on the same tailnet with Tailscale connected, HTTPS Certificates and MagicDNS enabled for it, a sign-in to both dashboards, and Ollama on both machines

The two Hubs are called Hub A (where you ask for the model) and Hub B (the one that has it). The names below, hub-a.example-tailnet.ts.net and hub-b.example-tailnet.ts.net, are placeholders for your own tailnet names. Each step has a dashboard route and a terminal route, and ends with a check.

What you’ll have at the end

  • Two Hubs that list each other under Paired Hubs as Connected
  • A request sent to Hub A for a model only Hub B holds, answered by Hub B’s engine, with proof on the response and in Recent routing on both Hubs
  • A clean way to switch pooling off or undo the pairing

This tutorial walks one path. For what every label and switch does, and for the limits of pooling — it adds capacity in whole requests, not one bigger GPU — see Hub Pool.

Check that both Hubs can be reached

Pairing, approval, and every health check go to https://<device>.<tailnet>.ts.net/, so each Hub has to be connected to the tailnet and published at that name. On each Hub’s machine, run:

cihub pool doctor

The command is read-only. Read section C: Tailnet should say the Hub is connected as its tailnet name, and Tailscale serve should say that name reaches the Hub. A failure there comes with the command to run; on a Linux host it is usually a one-time tailscale set --operator command. The first line of the report ends with all … checks passed when nothing needs attention. A Hub cannot reach its own tailnet name, so Tailscale serve may pass with a note that it was confirmed from the local Serve settings rather than by a request. That is still a pass; step 3 is the first real test across the tailnet. If cihub is not on your PATH, see Using the CLI.

In the dashboard, Settings → Network shows the same thing: Private VPN (Tailscale) reads Active with a Tailscale IP. If it shows Tailscale refused the Hub’s changes, copy the command it prints and run it once on that machine; it names the account the Hub runs as.

Check: on both Hubs, Tailnet and Tailscale serve pass, and Private VPN (Tailscale) reads Active. Do not continue until both do.

Generate a PIN on Hub B

The PIN proves you are at Hub B’s screen. On Hub B, open Settings → Network → Hub Pool, find the Pairing PIN block, and select Generate PIN.

From a terminal on Hub B instead:

cihub pool pairing-pin

That prints the six digits, when they expire, Hub B’s key fingerprint, and the exact cihub pool pair command to run on Hub A.

The Pairing PIN block on Hub B: this node's key fingerprint, six digits shown large beside a Cancel PIN button, and a field labelled PIN from the other Hub (optional).The Pairing PIN block on Hub B: this node's key fingerprint, six digits shown large beside a Cancel PIN button, and a field labelled PIN from the other Hub (optional).
Hub B after Generate PIN. The digits and the fingerprint are example data.

Check: six digits are on screen. They work once, for ten minutes, and generating another replaces them. Hub 0.3.0 leaves the digits showing after they expire, so note the time.

Send the request from Hub A

On Hub A, open Settings → Network → Hub Pool. Discoverable devices should list Hub B by its device name; the list can take a few seconds to appear because the Hub checks each candidate. Type the six digits into PIN from the other Hub (optional) — a shorter entry is dropped without a warning in 0.3.0 and the request goes out without a PIN — then select Pair next to Hub B.

The Pairing PIN block on Hub A with its key fingerprint, a Generate PIN button, and six digits typed into the PIN field. Under Discoverable devices, one Hub, hub-b, has a Pair button.The Pairing PIN block on Hub A with its key fingerprint, a Generate PIN button, and six digits typed into the PIN field. Under Discoverable devices, one Hub, hub-b, has a Pair button.
Hub A before it sends the request: the PIN typed in, and Hub B listed under Discoverable devices. Example data.

From a terminal on Hub A:

cihub pool pair hub-b.example-tailnet.ts.net --pin 123456

The command asks [y/N] before it sends; answer y, or add --yes.

Pending requests lists an outgoing request: hub-b.example-tailnet.ts.net, the text Waiting for hub-b.example-tailnet.ts.net to approve, and a Cancel request button.Pending requests lists an outgoing request: hub-b.example-tailnet.ts.net, the text Waiting for hub-b.example-tailnet.ts.net to approve, and a Cancel request button.
Hub A after Pair, waiting for Hub B to approve. Example data.

Check: the request moves to Pending requests and reads Waiting for hub-b.example-tailnet.ts.net to approve, with a Cancel request button. If the request is not listed a few seconds after you select Pair, it failed, even though Hub 0.3.0 flashed Pairing request sent. Jump to If it doesn’t work.

Approve it on Hub B

On Hub B, Settings → Network → Hub Pool → Pending requests shows hub-a.example-tailnet.ts.net, and, because the request carried the PIN, a Key fingerprint. On Hub A, the same card’s Pairing PIN block shows This node’s key fingerprint. They should match. Then select Approve.

Pending requests lists an incoming request from hub-a.example-tailnet.ts.net with the display name hub-a, a key fingerprint, and Reject and Approve buttons.Pending requests lists an incoming request from hub-a.example-tailnet.ts.net with the display name hub-a, a key fingerprint, and Reject and Approve buttons.
Hub B with the request that carried the PIN. Its key fingerprint matches the one on Hub A's Pairing PIN block. Example data.

From a terminal on Hub B:

cihub pool peers # find the row marked "in" and "pending"; note its id cihub pool approve <id>

The terminal does not print the requesting Hub’s fingerprint, so use the dashboard if you want to compare it.

Check: a Peer connected. message appears on Hub B, and its Paired Hubs table lists Hub A as Connected.

Confirm that both sides say Connected

Look at Hub A. Its page refreshes every 15 seconds, so give it a moment. Pending requests should be empty, Hub B should be under Paired Hubs as Connected, and the badge at the top of the card should read Routing.

cihub pool status

On both Hubs the Pooling line should read active, and the Peers line should show one connected.

Paired Hubs table with one row: hub-b, a Connected badge, hardware tier high, zero requests in flight, an Ollama engine with two models, the time it was last seen, a switch, and an Unpair button.Paired Hubs table with one row: hub-b, a Connected badge, hardware tier high, zero requests in flight, an Ollama engine with two models, the time it was last seen, a switch, and an Unpair button.
Hub A once Hub B has approved: hub-b reads Connected. Example data.

Check: both Hubs show Routing and one Connected peer. If Hub A still says Waiting for … to approve a minute after you approved, see I approved, but the other Hub still says Waiting.

Put a model on Hub B only

The proof needs a model Hub A does not have. If it had the model, it would serve the request itself, because a Hub prefers its own engines. On Hub B’s machine, pull a very small one:

ollama pull smollm2:135m

Within a minute or two, What the pool can serve on Hub A lists smollm2:135m, available on Hub B only. Hub B’s models arrive with each health check, which runs every 30 seconds by default.

Check: on Hub A, smollm2:135m appears in What the pool can serve under Hub B’s name, and the summary says on one node only.

Ask Hub A for it

Now send a request to Hub A for a model it does not hold. From Hub A’s machine:

curl -s -D - -o /dev/null http://localhost:5002/api/inference/pool/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "smollm2:135m", "messages": [{"role": "user", "content": "Say hello in five words."}]}'

The first request makes Hub B load the model, which can take a minute. The command prints only the response headers.

On Windows PowerShell, curl is an alias for a different command. Use curl.exe, and put the JSON in a file you pass with -d "@request.json". A request from outside the Hub’s own network needs an inference key; see Hub Pool.

Check: the headers include the line below, with Hub B’s tailnet name. A value of local means Hub A served it, so the model is not exclusive to Hub B.

X-Hub-Pool-Served-By: hub-b.example-tailnet.ts.net

See it in Recent routing on both Hubs

The routing log refreshes every 15 seconds. On Hub A, Recent routing shows Sent to hub-b.example-tailnet.ts.net with smollm2:135m and the time to first response. On Hub B, the same list shows Served for hub-a.example-tailnet.ts.net.

cihub pool log
Recent routing on Hub A: a summary reading 5 served, 0 failed, 1 failed over, above a table of five requests. Rows read Sent to local, Sent to hub-b with the model smollm2:135m, Served for hub-b, and Sent to hub-b with a +1 mark.Recent routing on Hub A: a summary reading 5 served, 0 failed, 1 failed over, above a table of five requests. Rows read Sent to local, Sent to hub-b with the model smollm2:135m, Served for hub-b, and Sent to hub-b with a +1 mark.
Recent routing on Hub A. The row that names smollm2:135m is the request that went to Hub B. Example data.

Check: one request, two rows: Sent to Hub B on Hub A, Served for Hub A on Hub B. That is pooling working end to end.

If it doesn’t work

SymptomCause and fix
Hub B is not listed, or Pair flashes Pairing request sent. but nothing appears under Pending requestsThe other Hub cannot be reached at https://<name>/. Re-run step 1 on Hub B, and check HTTPS Certificates and MagicDNS. The dashboard does not show the reason; it is in Hub A’s log under Settings → Logs. A refused PIN reads Peer refused the pairing PIN there — generate a fresh one and retype all six digits.
Hub A says Waiting for … to approve after you approved on Hub BWhen you approve, Hub B calls Hub A’s https://<name>/ to finish, and that call is not retried. Hub A is not reachable or published. Fix it with step 1 on Hub A, then Cancel request on Hub A, Unpair on Hub B, and start again.
Both say Connected, but the request fails with No pool node currently has modelThe model is not listed yet (wait for Hub B’s next health check), the name differs (it must match exactly, tag included), or Hub B’s row shows Disabled or a paused tag.
The response says X-Hub-Pool-Served-By: localHub A holds that model too, and a Hub prefers its own engines. Ask for a model that only Hub B has.

More cases, including Unreachable and Needs re-pair, are in Hub Pool troubleshooting.

Undo it

  • Keep the pairing and stop pooling. On either Hub, turn off Pool inference with paired Hubs under Controls. Every request stays on that Hub, and turning the switch back on needs no new approval.
  • Remove the pairing. On either Hub, select Unpair next to the other Hub and confirm. The Hub tells the other one to drop its half; if that Hub was off, unpair there too. From a terminal: cihub pool unpair <id>.
  • Remove the test model. On Hub B’s machine, ollama rm smollm2:135m.
  • Cancel a PIN you did not use. Select Cancel PIN, or run cihub pool cancel-pin.

Next

Last updated on